forked from farhoodlabs/paperclip
Merge public/master into pap-1239-server-test-isolation
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: prcheckloop
|
||||
description: >
|
||||
Iteratively gets a GitHub pull request's checks green. Detects the PR for the
|
||||
current branch or uses a provided PR number, waits for every check on the
|
||||
latest head SHA to appear and finish, investigates failing checks, fixes
|
||||
actionable code or test issues, pushes, and repeats. Escalates with a precise
|
||||
blocker when failures are external, flaky, or not safely fixable. Use when a
|
||||
PR still has unsuccessful checks after review fixes, including after greploop.
|
||||
---
|
||||
|
||||
# PRCheckloop
|
||||
|
||||
Get a GitHub PR to a fully green check state, or exit with a concrete blocker.
|
||||
|
||||
## Scope
|
||||
|
||||
- GitHub PRs only. If the repo is GitLab, stop and use `check-pr`.
|
||||
- Focus on checks for the latest PR head SHA, not old commits.
|
||||
- Focus on CI/status checks, not review comments or PR template cleanup.
|
||||
- If the user also wants review-comment cleanup, pair this with `check-pr`.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **PR number** (optional): If not provided, detect the PR for the current branch.
|
||||
- **Max iterations**: default `5`.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the PR
|
||||
|
||||
If no PR number is provided, detect it from the current branch:
|
||||
|
||||
```bash
|
||||
gh pr view --json number,headRefName,headRefOid,url,isDraft
|
||||
```
|
||||
|
||||
If needed, switch to the PR branch before making changes.
|
||||
|
||||
Stop early if:
|
||||
|
||||
- `gh` is not authenticated
|
||||
- there is no PR for the branch
|
||||
- the repo is not hosted on GitHub
|
||||
|
||||
### 2. Track the latest head SHA
|
||||
|
||||
Always work against the current PR head SHA:
|
||||
|
||||
```bash
|
||||
PR_JSON=$(gh pr view "$PR_NUMBER" --json number,headRefName,headRefOid,url)
|
||||
HEAD_SHA=$(echo "$PR_JSON" | jq -r .headRefOid)
|
||||
PR_URL=$(echo "$PR_JSON" | jq -r .url)
|
||||
```
|
||||
|
||||
Ignore failing checks from older SHAs. After every push, refresh `HEAD_SHA` and
|
||||
restart the inspection loop.
|
||||
|
||||
### 3. Inventory checks for that SHA
|
||||
|
||||
Fetch both GitHub check runs and legacy commit status contexts:
|
||||
|
||||
```bash
|
||||
gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/check-runs?per_page=100"
|
||||
gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/status"
|
||||
```
|
||||
|
||||
For a compact PR-level view, this GraphQL payload is useful:
|
||||
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
query($owner:String!, $repo:String!, $pr:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$pr) {
|
||||
headRefOid
|
||||
url
|
||||
statusCheckRollup {
|
||||
contexts(first:100) {
|
||||
nodes {
|
||||
__typename
|
||||
... on CheckRun { name status conclusion detailsUrl workflowName }
|
||||
... on StatusContext { context state targetUrl description }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner=OWNER -F repo=REPO -F pr="$PR_NUMBER"
|
||||
```
|
||||
|
||||
### 4. Wait for checks to actually run
|
||||
|
||||
After a new push, checks can take a moment to appear. Poll every 15-30 seconds
|
||||
until one of these is true:
|
||||
|
||||
- checks have appeared and every item is in a terminal state
|
||||
- checks have appeared and at least one failed
|
||||
- no checks appear after a reasonable wait, usually 2 minutes
|
||||
|
||||
Treat these as terminal success states:
|
||||
|
||||
- check runs: `SUCCESS`, `NEUTRAL`, `SKIPPED`
|
||||
- status contexts: `SUCCESS`
|
||||
|
||||
Treat these as pending:
|
||||
|
||||
- check runs: `QUEUED`, `PENDING`, `WAITING`, `REQUESTED`, `IN_PROGRESS`
|
||||
- status contexts: `PENDING`
|
||||
|
||||
Treat these as failures:
|
||||
|
||||
- check runs: `FAILURE`, `TIMED_OUT`, `CANCELLED`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`
|
||||
- status contexts: `FAILURE`, `ERROR`
|
||||
|
||||
If no checks appear for the latest SHA, inspect `.github/workflows/`, workflow
|
||||
path filters, and branch protection expectations. If the missing check cannot be
|
||||
caused or fixed from the repo, escalate.
|
||||
|
||||
### 5. Investigate failing checks
|
||||
|
||||
For GitHub Actions failures, inspect runs and failed logs for the current SHA:
|
||||
|
||||
```bash
|
||||
gh run list --commit "$HEAD_SHA" --json databaseId,workflowName,status,conclusion,url,headSha
|
||||
gh run view <RUN_ID> --json databaseId,name,workflowName,status,conclusion,jobs,url,headSha
|
||||
gh run view <RUN_ID> --log-failed
|
||||
```
|
||||
|
||||
For each failing check, classify it:
|
||||
|
||||
| Failure type | Action |
|
||||
|---|---|
|
||||
| Code/test regression | Reproduce locally, fix, and verify |
|
||||
| Lint/type/build mismatch | Run the matching local command from the workflow and fix it |
|
||||
| Flake or transient infra issue | Rerun once if evidence supports flakiness |
|
||||
| External service/status app failure | Escalate with the details URL and owner guess |
|
||||
| Missing secret/permission/branch protection issue | Escalate immediately |
|
||||
|
||||
Only rerun a failed job once without code changes. Do not loop on reruns.
|
||||
|
||||
### 6. Fix actionable failures
|
||||
|
||||
If the failure is actionable from the checked-out code:
|
||||
|
||||
1. Read the workflow or failing command to identify the real gate.
|
||||
2. Reproduce locally where reasonable.
|
||||
3. Make the smallest correct fix.
|
||||
4. Run focused verification first, then broader verification if needed.
|
||||
5. Commit in a logical commit.
|
||||
6. Push before re-checking the PR.
|
||||
|
||||
Do not stop at a local fix. The loop is only complete when the remote PR checks
|
||||
for the new head SHA are green.
|
||||
|
||||
### 7. Push and repeat
|
||||
|
||||
After each fix:
|
||||
|
||||
```bash
|
||||
git push
|
||||
sleep 5
|
||||
```
|
||||
|
||||
Then refresh the PR metadata, get the new `HEAD_SHA`, and restart from Step 3.
|
||||
|
||||
Exit the loop only when:
|
||||
|
||||
- all checks for the latest head SHA are green, or
|
||||
- a blocker remains after reasonable repair effort, or
|
||||
- the max iteration count is reached
|
||||
|
||||
### 8. Escalate blockers precisely
|
||||
|
||||
If you cannot get the PR green, report:
|
||||
|
||||
- PR URL
|
||||
- latest head SHA
|
||||
- exact failing or missing check names
|
||||
- details URLs
|
||||
- what you already tried
|
||||
- why it is blocked
|
||||
- who should likely unblock it
|
||||
- the next concrete action
|
||||
|
||||
Good blocker examples:
|
||||
|
||||
- external status app outage
|
||||
- missing GitHub secret or permission
|
||||
- required check name mismatch in branch protection
|
||||
- persistent flake after one rerun
|
||||
- failure needs credentials or infrastructure access you do not have
|
||||
|
||||
## Output
|
||||
|
||||
When the skill completes, report:
|
||||
|
||||
- PR URL and branch
|
||||
- final head SHA
|
||||
- green/pending/failing check summary
|
||||
- fixes made and verification run
|
||||
- whether changes were pushed
|
||||
- blocker summary if not fully green
|
||||
|
||||
## Notes
|
||||
|
||||
- This skill is intentionally narrower than `check-pr`: it is a repair loop for
|
||||
PR checks.
|
||||
- This skill complements `greploop`: Greptile can be perfect while CI is still
|
||||
red.
|
||||
@@ -81,8 +81,8 @@ If you change schema/API behavior, update all impacted layers:
|
||||
4. Do not replace strategic docs wholesale unless asked.
|
||||
Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned.
|
||||
|
||||
5. Keep plan docs dated and centralized.
|
||||
New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames.
|
||||
5. Keep repo plan docs dated and centralized.
|
||||
When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file.
|
||||
|
||||
## 6. Database Change Workflow
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
readSourceAttachmentBody,
|
||||
rebindWorkspaceCwd,
|
||||
resolveSourceConfigPath,
|
||||
resolveWorktreeReseedSource,
|
||||
resolveWorktreeReseedTargetPaths,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeInitCommand,
|
||||
@@ -482,27 +484,69 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an explicit source for worktree reseed", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-source-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
it("requires an explicit reseed source", () => {
|
||||
expect(() => resolveWorktreeReseedSource({})).toThrow(
|
||||
"Pass --from <worktree> or --from-config/--from-instance explicitly so the reseed source is unambiguous.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects mixed reseed source selectors", () => {
|
||||
expect(() => resolveWorktreeReseedSource({
|
||||
from: "current",
|
||||
fromInstance: "default",
|
||||
})).toThrow(
|
||||
"Use either --from <worktree> or --from-config/--from-data-dir/--from-instance, not both.",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives worktree reseed target paths from the adjacent env file", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-"));
|
||||
const worktreeRoot = path.join(tempRoot, "repo");
|
||||
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
|
||||
const envPath = path.join(worktreeRoot, ".paperclip", ".env");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await expect(worktreeReseedCommand({ seed: false, yes: true })).rejects.toThrow(
|
||||
"Reseed requires an explicit source.",
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
fs.writeFileSync(
|
||||
envPath,
|
||||
[
|
||||
"PAPERCLIP_HOME=/tmp/paperclip-worktrees",
|
||||
"PAPERCLIP_INSTANCE_ID=pap-1132-chat",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
expect(
|
||||
resolveWorktreeReseedTargetPaths({
|
||||
configPath,
|
||||
rootPath: worktreeRoot,
|
||||
}),
|
||||
).toMatchObject({
|
||||
cwd: worktreeRoot,
|
||||
homeDir: "/tmp/paperclip-worktrees",
|
||||
instanceId: "pap-1132-chat",
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects reseed targets without worktree env metadata", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-missing-"));
|
||||
const worktreeRoot = path.join(tempRoot, "repo");
|
||||
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
fs.writeFileSync(path.join(worktreeRoot, ".paperclip", ".env"), "", "utf8");
|
||||
|
||||
expect(() =>
|
||||
resolveWorktreeReseedTargetPaths({
|
||||
configPath,
|
||||
rootPath: worktreeRoot,
|
||||
})).toThrow("does not look like a worktree-local Paperclip instance");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
} else {
|
||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -529,6 +573,7 @@ describe("worktree helpers", () => {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true });
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
fs.mkdirSync(sourceRoot, { recursive: true });
|
||||
|
||||
@@ -546,6 +591,7 @@ describe("worktree helpers", () => {
|
||||
});
|
||||
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
|
||||
fs.writeFileSync(
|
||||
currentPaths.envPath,
|
||||
[
|
||||
@@ -562,7 +608,6 @@ describe("worktree helpers", () => {
|
||||
|
||||
await worktreeReseedCommand({
|
||||
fromConfig: sourcePaths.configPath,
|
||||
seed: false,
|
||||
yes: true,
|
||||
});
|
||||
|
||||
@@ -584,7 +629,7 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
it("restores the current worktree config and instance data if reseed fails", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
|
||||
|
||||
+180
-181
@@ -98,22 +98,6 @@ type WorktreeMakeOptions = WorktreeInitOptions & {
|
||||
startPoint?: string;
|
||||
};
|
||||
|
||||
type WorktreeReseedOptions = {
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
home?: string;
|
||||
seedMode?: string;
|
||||
yes?: boolean;
|
||||
seed?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeReseedBackup = {
|
||||
tempRoot: string;
|
||||
repoConfigDirBackup: string | null;
|
||||
instanceRootBackup: string | null;
|
||||
};
|
||||
|
||||
type WorktreeEnvOptions = {
|
||||
config?: string;
|
||||
json?: boolean;
|
||||
@@ -133,6 +117,17 @@ type WorktreeMergeHistoryOptions = {
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeReseedOptions = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
seedMode?: string;
|
||||
yes?: boolean;
|
||||
allowLiveTarget?: boolean;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
@@ -738,6 +733,65 @@ export function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
|
||||
}
|
||||
|
||||
export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): ResolvedWorktreeReseedSource {
|
||||
const fromSelector = nonEmpty(input.from);
|
||||
const fromConfig = nonEmpty(input.fromConfig);
|
||||
const fromDataDir = nonEmpty(input.fromDataDir);
|
||||
const fromInstance = nonEmpty(input.fromInstance);
|
||||
const hasExplicitConfigSource = Boolean(fromConfig || fromDataDir || fromInstance);
|
||||
|
||||
if (fromSelector && hasExplicitConfigSource) {
|
||||
throw new Error(
|
||||
"Use either --from <worktree> or --from-config/--from-data-dir/--from-instance, not both.",
|
||||
);
|
||||
}
|
||||
|
||||
if (fromSelector) {
|
||||
const endpoint = resolveWorktreeEndpointFromSelector(fromSelector, { allowCurrent: true });
|
||||
return {
|
||||
configPath: endpoint.configPath,
|
||||
label: endpoint.label,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasExplicitConfigSource) {
|
||||
const configPath = resolveSourceConfigPath({
|
||||
fromConfig: fromConfig ?? undefined,
|
||||
fromDataDir: fromDataDir ?? undefined,
|
||||
fromInstance: fromInstance ?? undefined,
|
||||
});
|
||||
return {
|
||||
configPath,
|
||||
label: configPath,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Pass --from <worktree> or --from-config/--from-instance explicitly so the reseed source is unambiguous.",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveWorktreeReseedTargetPaths(input: {
|
||||
configPath: string;
|
||||
rootPath: string;
|
||||
}): WorktreeLocalPaths {
|
||||
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(input.configPath));
|
||||
const homeDir = nonEmpty(envEntries.PAPERCLIP_HOME);
|
||||
const instanceId = nonEmpty(envEntries.PAPERCLIP_INSTANCE_ID);
|
||||
|
||||
if (!homeDir || !instanceId) {
|
||||
throw new Error(
|
||||
`Target config ${input.configPath} does not look like a worktree-local Paperclip instance. Expected PAPERCLIP_HOME and PAPERCLIP_INSTANCE_ID in the adjacent .env.`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolveWorktreeLocalPaths({
|
||||
cwd: input.rootPath,
|
||||
homeDir,
|
||||
instanceId,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||
if (config.database.mode === "postgres") {
|
||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||
@@ -894,6 +948,8 @@ async function seedWorktreeDatabase(input: {
|
||||
input.sourceConfig.database.embeddedPostgresDataDir,
|
||||
input.sourceConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
const sourceAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${sourceHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(sourceAdminConnectionString, "paperclip");
|
||||
}
|
||||
const sourceConnectionString = resolveSourceConnectionString(
|
||||
input.sourceConfig,
|
||||
@@ -1068,160 +1124,6 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
||||
await runWorktreeInit(opts);
|
||||
}
|
||||
|
||||
function hasExplicitSourceSelection(opts: {
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
sourceConfigPathOverride?: string;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
nonEmpty(opts.fromConfig)
|
||||
|| nonEmpty(opts.fromDataDir)
|
||||
|| nonEmpty(opts.fromInstance)
|
||||
|| nonEmpty(opts.sourceConfigPathOverride),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCurrentWorktreeReseedState(opts: { home?: string } = {}) {
|
||||
const currentConfigPath = resolveConfigPath();
|
||||
if (!existsSync(currentConfigPath)) {
|
||||
throw new Error(
|
||||
"Current directory does not have a Paperclip worktree config. Run `paperclipai worktree init` here first.",
|
||||
);
|
||||
}
|
||||
const currentConfig = readConfig(currentConfigPath);
|
||||
if (!currentConfig) {
|
||||
throw new Error(`Could not read current worktree config at ${currentConfigPath}.`);
|
||||
}
|
||||
if (currentConfig.database.mode !== "embedded-postgres") {
|
||||
throw new Error("Worktree reseed only supports embedded-postgres worktree instances.");
|
||||
}
|
||||
|
||||
const currentEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(currentConfigPath));
|
||||
const instanceRoot = path.dirname(currentConfig.database.embeddedPostgresDataDir);
|
||||
const derivedHomeDir = path.dirname(path.dirname(instanceRoot));
|
||||
|
||||
return {
|
||||
currentConfigPath: path.resolve(currentConfigPath),
|
||||
instanceId:
|
||||
nonEmpty(currentEnvEntries.PAPERCLIP_INSTANCE_ID)
|
||||
?? nonEmpty(path.basename(instanceRoot))
|
||||
?? sanitizeWorktreeInstanceId(path.basename(process.cwd())),
|
||||
homeDir: path.resolve(expandHomePrefix(opts.home ?? currentEnvEntries.PAPERCLIP_HOME ?? derivedHomeDir)),
|
||||
serverPort: currentConfig.server.port,
|
||||
dbPort: currentConfig.database.embeddedPostgresPort,
|
||||
worktreeName: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_NAME) ?? undefined,
|
||||
worktreeColor: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_COLOR) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function snapshotDirectory(sourcePath: string, targetPath: string): Promise<string | null> {
|
||||
if (!existsSync(sourcePath)) {
|
||||
return null;
|
||||
}
|
||||
await fsPromises.cp(sourcePath, targetPath, { recursive: true });
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async function snapshotWorktreeReseedState(target: {
|
||||
repoConfigDir: string;
|
||||
instanceRoot: string;
|
||||
}): Promise<WorktreeReseedBackup> {
|
||||
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-reseed-backup-"));
|
||||
return {
|
||||
tempRoot,
|
||||
repoConfigDirBackup: await snapshotDirectory(
|
||||
target.repoConfigDir,
|
||||
path.resolve(tempRoot, "repo-config"),
|
||||
),
|
||||
instanceRootBackup: await snapshotDirectory(
|
||||
target.instanceRoot,
|
||||
path.resolve(tempRoot, "instance-root"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function restoreDirectoryBackup(backupPath: string | null, targetPath: string): Promise<void> {
|
||||
rmSync(targetPath, { recursive: true, force: true });
|
||||
if (!backupPath) {
|
||||
return;
|
||||
}
|
||||
await fsPromises.cp(backupPath, targetPath, { recursive: true });
|
||||
}
|
||||
|
||||
async function restoreWorktreeReseedState(
|
||||
backup: WorktreeReseedBackup,
|
||||
target: { repoConfigDir: string; instanceRoot: string },
|
||||
): Promise<void> {
|
||||
await restoreDirectoryBackup(backup.repoConfigDirBackup, target.repoConfigDir);
|
||||
await restoreDirectoryBackup(backup.instanceRootBackup, target.instanceRoot);
|
||||
}
|
||||
|
||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
||||
|
||||
if (!hasExplicitSourceSelection(opts)) {
|
||||
throw new Error(
|
||||
"Reseed requires an explicit source. Pass --from-config or --from-instance (optionally with --from-data-dir).",
|
||||
);
|
||||
}
|
||||
|
||||
const target = resolveCurrentWorktreeReseedState({ home: opts.home });
|
||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||
if (path.resolve(sourceConfigPath) === target.currentConfigPath) {
|
||||
throw new Error(
|
||||
"Source and target Paperclip configs are the same. Pass a different source instance/config when reseeding.",
|
||||
);
|
||||
}
|
||||
|
||||
const seedMode = opts.seedMode ?? "minimal";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
}
|
||||
|
||||
const confirmed = opts.yes
|
||||
? true
|
||||
: await p.confirm({
|
||||
message: `Reseed the current worktree instance (${target.instanceId}) from ${sourceConfigPath}? This overwrites only the current worktree Paperclip instance data.`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.log.warn("Reseed cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPaths = resolveWorktreeLocalPaths({
|
||||
cwd: process.cwd(),
|
||||
homeDir: target.homeDir,
|
||||
instanceId: target.instanceId,
|
||||
});
|
||||
const backup = await snapshotWorktreeReseedState(targetPaths);
|
||||
|
||||
try {
|
||||
await runWorktreeInit({
|
||||
name: target.worktreeName,
|
||||
color: target.worktreeColor,
|
||||
instance: target.instanceId,
|
||||
home: target.homeDir,
|
||||
fromConfig: opts.fromConfig,
|
||||
fromDataDir: opts.fromDataDir,
|
||||
fromInstance: opts.fromInstance,
|
||||
sourceConfigPathOverride: sourceConfigPath,
|
||||
serverPort: target.serverPort,
|
||||
dbPort: target.dbPort,
|
||||
seed: opts.seed ?? true,
|
||||
seedMode,
|
||||
force: true,
|
||||
});
|
||||
} catch (error) {
|
||||
await restoreWorktreeReseedState(backup, targetPaths);
|
||||
throw error;
|
||||
} finally {
|
||||
rmSync(backup.tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
||||
@@ -1326,6 +1228,11 @@ type ResolvedWorktreeEndpoint = {
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
type ResolvedWorktreeReseedSource = {
|
||||
configPath: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
||||
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
@@ -1819,6 +1726,13 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function resolveRunningEmbeddedPostgresPid(config: PaperclipConfig): number | null {
|
||||
if (config.database.mode !== "embedded-postgres") {
|
||||
return null;
|
||||
}
|
||||
return readRunningPostmasterPid(path.resolve(config.database.embeddedPostgresDataDir, "postmaster.pid"));
|
||||
}
|
||||
|
||||
async function collectMergePlan(input: {
|
||||
sourceDb: ClosableDb;
|
||||
targetDb: ClosableDb;
|
||||
@@ -2760,6 +2674,89 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
||||
|
||||
const seedMode = opts.seedMode ?? "full";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
}
|
||||
|
||||
const targetEndpoint = opts.to
|
||||
? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true })
|
||||
: resolveCurrentEndpoint();
|
||||
const source = resolveWorktreeReseedSource(opts);
|
||||
|
||||
if (path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath)) {
|
||||
throw new Error("Source and target Paperclip configs are the same. Choose different --from/--to values.");
|
||||
}
|
||||
if (!existsSync(source.configPath)) {
|
||||
throw new Error(`Source config not found at ${source.configPath}.`);
|
||||
}
|
||||
|
||||
const targetConfig = readConfig(targetEndpoint.configPath);
|
||||
if (!targetConfig) {
|
||||
throw new Error(`Target config not found at ${targetEndpoint.configPath}.`);
|
||||
}
|
||||
const sourceConfig = readConfig(source.configPath);
|
||||
if (!sourceConfig) {
|
||||
throw new Error(`Source config not found at ${source.configPath}.`);
|
||||
}
|
||||
|
||||
const targetPaths = resolveWorktreeReseedTargetPaths({
|
||||
configPath: targetEndpoint.configPath,
|
||||
rootPath: targetEndpoint.rootPath,
|
||||
});
|
||||
const runningTargetPid = resolveRunningEmbeddedPostgresPid(targetConfig);
|
||||
if (runningTargetPid && !opts.allowLiveTarget) {
|
||||
throw new Error(
|
||||
`Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${targetEndpoint.rootPath} before reseeding, or re-run with --allow-live-target if you want to override this guard.`,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmed = opts.yes
|
||||
? true
|
||||
: await p.confirm({
|
||||
message: `Overwrite the isolated Paperclip DB for ${targetEndpoint.label} from ${source.label} using ${seedMode} seed mode?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.log.warn("Reseed cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (runningTargetPid && opts.allowLiveTarget) {
|
||||
p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`);
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Reseeding ${targetEndpoint.label} from ${source.label} (${seedMode})...`);
|
||||
try {
|
||||
const seeded = await seedWorktreeDatabase({
|
||||
sourceConfigPath: source.configPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
targetPaths,
|
||||
instanceId: targetPaths.instanceId,
|
||||
seedMode,
|
||||
});
|
||||
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
|
||||
p.log.message(pc.dim(`Source: ${source.configPath}`));
|
||||
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
|
||||
p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`));
|
||||
for (const rebound of seeded.reboundWorkspaces) {
|
||||
p.log.message(
|
||||
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
||||
);
|
||||
}
|
||||
p.outro(pc.green(`Reseed complete for ${targetEndpoint.label}.`));
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to reseed worktree database."));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorktreeCommands(program: Command): void {
|
||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||
|
||||
@@ -2803,17 +2800,6 @@ export function registerWorktreeCommands(program: Command): void {
|
||||
.option("--json", "Print JSON instead of shell exports")
|
||||
.action(worktreeEnvCommand);
|
||||
|
||||
worktree
|
||||
.command("reseed")
|
||||
.description("Replace the current worktree instance with a fresh seed while preserving this worktree's ports and instance id")
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config")
|
||||
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||
.option("--yes", "Skip the destructive confirmation prompt", false)
|
||||
.action(worktreeReseedCommand);
|
||||
|
||||
program
|
||||
.command("worktree:list")
|
||||
.description("List git worktrees visible from this repo and whether they look like Paperclip worktrees")
|
||||
@@ -2833,6 +2819,19 @@ export function registerWorktreeCommands(program: Command): void {
|
||||
.option("--yes", "Skip the interactive confirmation prompt when applying", false)
|
||||
.action(worktreeMergeHistoryCommand);
|
||||
|
||||
worktree
|
||||
.command("reseed")
|
||||
.description("Re-seed an existing worktree-local instance from another Paperclip instance or worktree")
|
||||
.option("--from <worktree>", "Source worktree path, directory name, branch name, or current")
|
||||
.option("--to <worktree>", "Target worktree path, directory name, branch name, or current (defaults to current)")
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config")
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: full)", "full")
|
||||
.option("--yes", "Skip the destructive confirmation prompt", false)
|
||||
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
||||
.action(worktreeReseedCommand);
|
||||
|
||||
program
|
||||
.command("worktree:cleanup")
|
||||
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
||||
|
||||
+29
-16
@@ -232,14 +232,38 @@ pnpm paperclipai worktree init --force --seed-mode minimal \
|
||||
|
||||
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
|
||||
|
||||
For existing worktrees, prefer the dedicated reseed command instead of rebuilding the `worktree init --force` flags manually:
|
||||
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
|
||||
|
||||
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--from <worktree>` | Source worktree path, directory name, branch name, or `current` |
|
||||
| `--to <worktree>` | Target worktree path, directory name, branch name, or `current` (defaults to `current`) |
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id when deriving the source config |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `full`) |
|
||||
| `--yes` | Skip the destructive confirmation prompt |
|
||||
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
cd /path/to/existing/worktree
|
||||
pnpm paperclipai worktree reseed --from-config /path/to/source/.paperclip/config.json --seed-mode full
|
||||
```
|
||||
# From the main repo, reseed a worktree from the current default/master instance.
|
||||
cd /path/to/paperclip
|
||||
pnpm paperclipai worktree reseed \
|
||||
--from current \
|
||||
--to PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat \
|
||||
--seed-mode full \
|
||||
--yes
|
||||
|
||||
`worktree reseed` preserves the current worktree's instance id, ports, and branding while replacing only that worktree's isolated Paperclip instance data from the chosen source.
|
||||
# From inside a worktree, reseed it from the default instance config.
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
pnpm paperclipai worktree reseed \
|
||||
--from-instance default \
|
||||
--seed-mode full
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||
|
||||
@@ -267,17 +291,6 @@ pnpm paperclipai worktree:make experiment --no-seed
|
||||
|
||||
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
|
||||
|
||||
**`pnpm paperclipai worktree reseed [options]`** — Replace the current worktree instance with a fresh seed from another Paperclip source while preserving the current worktree's ports and instance id.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id when deriving the source config |
|
||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
||||
| `--yes` | Skip the destructive confirmation prompt |
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `-c, --config <path>` | Path to config file |
|
||||
|
||||
@@ -115,6 +115,38 @@ If the first real publish returns npm `E404`, check npm-side prerequisites befor
|
||||
- The initial publish must include `--access public` for a public scoped package.
|
||||
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
|
||||
|
||||
### Manual first publish for `@paperclipai/mcp-server`
|
||||
|
||||
If you need to publish only the MCP server package once by hand, use:
|
||||
|
||||
- `@paperclipai/mcp-server`
|
||||
|
||||
Recommended flow from the repo root:
|
||||
|
||||
```bash
|
||||
# optional sanity check: this 404s until the first publish exists
|
||||
npm view @paperclipai/mcp-server version
|
||||
|
||||
# make sure the build output is fresh
|
||||
pnpm --filter @paperclipai/mcp-server build
|
||||
|
||||
# confirm your local npm auth before the real publish
|
||||
npm whoami
|
||||
|
||||
# safe preview of the exact publish payload
|
||||
cd packages/mcp-server
|
||||
pnpm publish --dry-run --no-git-checks --access public
|
||||
|
||||
# real publish
|
||||
pnpm publish --no-git-checks --access public
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Publish from `packages/mcp-server/`, not the repo root.
|
||||
- If `npm view @paperclipai/mcp-server version` already returns the same version that is in [`packages/mcp-server/package.json`](../packages/mcp-server/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
|
||||
- The same npm-side prerequisites apply as above: valid npm auth, permission to publish to the `@paperclipai` scope, `--access public`, and the required publish auth/2FA policy.
|
||||
|
||||
## Version formats
|
||||
|
||||
Paperclip uses calendar versions:
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
# 2026-04-07 Issue Detail Speed And Optimistic Inventory
|
||||
|
||||
Status: Proposed
|
||||
Date: 2026-04-07
|
||||
Audience: Product and engineering
|
||||
Related:
|
||||
- `ui/src/pages/IssueDetail.tsx`
|
||||
- `ui/src/components/IssueProperties.tsx`
|
||||
- `ui/src/api/issues.ts`
|
||||
- `ui/src/lib/queryKeys.ts`
|
||||
- `server/src/routes/issues.ts`
|
||||
- `server/src/services/issues.ts`
|
||||
- [PAP-1192](/PAP/issues/PAP-1192)
|
||||
- [PAP-1191](/PAP/issues/PAP-1191)
|
||||
- [PAP-1188](/PAP/issues/PAP-1188)
|
||||
- [PAP-1119](/PAP/issues/PAP-1119)
|
||||
- [PAP-945](/PAP/issues/PAP-945)
|
||||
- [PAP-1165](/PAP/issues/PAP-1165)
|
||||
- [PAP-890](/PAP/issues/PAP-890)
|
||||
- [PAP-254](/PAP/issues/PAP-254)
|
||||
- [PAP-138](/PAP/issues/PAP-138)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This note inventories the Paperclip issues that point to the same UX class of problem:
|
||||
|
||||
- pages feel slow because they over-fetch or refetch too much
|
||||
- actions feel slow because the UI waits for the round trip before reflecting obvious local intent
|
||||
- optimistic updates exist in some places, but not in a consistent system
|
||||
|
||||
The immediate trigger is [PAP-1192](/PAP/issues/PAP-1192): the issue detail page now feels very slow.
|
||||
|
||||
## 2. Short Answer
|
||||
|
||||
The issue detail page is not obviously blocked by one pathological endpoint. The main problem is the shape of the page:
|
||||
|
||||
- `IssueDetail` fans out into many independent queries on mount
|
||||
- some of those queries fetch full company-wide collections for data that is local to one issue
|
||||
- common mutations invalidate almost every issue-related query, which creates avoidable refetch storms
|
||||
- the page has only a minimal top-level `Loading...` fallback and very little staged or sectional loading UX
|
||||
|
||||
Measured against the current assigned issue (`PAP-1191`) on local dev, the slowest single request was the full company issues list:
|
||||
|
||||
- `GET /api/issues/:id` about `18ms`
|
||||
- `GET /api/issues/:id/comments|activity|approvals|attachments` about `6-8ms`
|
||||
- `GET /api/companies/:companyId/agents|projects` about `9-11ms`
|
||||
- `GET /api/companies/:companyId/issues` about `76ms`
|
||||
|
||||
That strongly suggests the current pain is aggregate client fan-out plus over-broad invalidation, not one obviously broken endpoint.
|
||||
|
||||
## 3. Similar Issue Inventory
|
||||
|
||||
## 3.1 Issue-detail and issue-action siblings
|
||||
|
||||
- [PAP-1192](/PAP/issues/PAP-1192): issue page feels like it loads forever
|
||||
- [PAP-1188](/PAP/issues/PAP-1188): assignee changes in the issue properties pane were slow and needed optimistic UI
|
||||
- [PAP-945](/PAP/issues/PAP-945): optimistic comment rendering
|
||||
- [PAP-1003](/PAP/issues/PAP-1003): optimistic comments had duplicate draft/pending behavior
|
||||
- [PAP-947](/PAP/issues/PAP-947): follow-up breakage from optimistic comments
|
||||
- [PAP-254](/PAP/issues/PAP-254): long issue threads become sluggish when adding comments
|
||||
- [PAP-189](/PAP/issues/PAP-189): comment semantics while an issue has a live run
|
||||
|
||||
Pattern: the issue page already has a history of needing both optimistic behavior and bounded thread/loading behavior. `PAP-1192` is the same family, not a new category.
|
||||
|
||||
## 3.2 Inbox and list-view siblings
|
||||
|
||||
- [PAP-1119](/PAP/issues/PAP-1119): optimistic archive had fade-out then snap-back
|
||||
- [PAP-1165](/PAP/issues/PAP-1165): issue search slow
|
||||
- [PAP-890](/PAP/issues/PAP-890): issue search slow, make it very fast
|
||||
- [PAP-138](/PAP/issues/PAP-138): inbox loading feels stuck
|
||||
- [PAP-470](/PAP/issues/PAP-470): create-issue save state felt slow and awkward
|
||||
|
||||
Pattern: Paperclip already has several places where the right fix was "show intent immediately, then reconcile," not "wait for refetch."
|
||||
|
||||
## 3.3 Broader app-loading siblings
|
||||
|
||||
- [PAP-472](/PAP/issues/PAP-472): dashboard charts load very slowly
|
||||
- [PAP-797](/PAP/issues/PAP-797): reduce loading states through static generation/caching where possible
|
||||
- [PAP-799](/PAP/issues/PAP-799): embed company data at build time to eliminate loading states
|
||||
- [PAP-703](/PAP/issues/PAP-703): faster chat and better visual feedback
|
||||
|
||||
Pattern: the product has recurring pressure to reduce blank/loading states across the app, so the issue-detail work should fit that broader direction.
|
||||
|
||||
## 4. Current Issue Detail Findings
|
||||
|
||||
## 4.1 Mount query fan-out is high
|
||||
|
||||
`ui/src/pages/IssueDetail.tsx` mounts all of these data sources up front:
|
||||
|
||||
- issue detail
|
||||
- comments
|
||||
- activity
|
||||
- linked runs
|
||||
- linked approvals
|
||||
- attachments
|
||||
- live runs
|
||||
- active run
|
||||
- full company issues list
|
||||
- agents list
|
||||
- auth session
|
||||
- projects list
|
||||
- feedback votes
|
||||
- instance general settings
|
||||
- plugin slots
|
||||
|
||||
This is too much for the initial view of a single issue.
|
||||
|
||||
## 4.2 The page fetches full company issue data just to derive child issues
|
||||
|
||||
`IssueDetail` currently does:
|
||||
|
||||
- `issuesApi.list(selectedCompanyId!)`
|
||||
- then filters client-side for `parentId === issue.id`
|
||||
|
||||
That is expensive relative to the need.
|
||||
|
||||
Important detail:
|
||||
|
||||
- the server route already supports `parentId`
|
||||
- `server/src/services/issues.ts` already supports `parentId`
|
||||
- but `ui/src/api/issues.ts` does not expose `parentId` in the filter type
|
||||
|
||||
So the client is missing an already-supported narrow query path.
|
||||
|
||||
## 4.3 Comments are still fetched as full-thread loads
|
||||
|
||||
`server/src/routes/issues.ts` and `server/src/services/issues.ts` already support:
|
||||
|
||||
- `after`
|
||||
- `order`
|
||||
- `limit`
|
||||
|
||||
But `IssueDetail` still calls `issuesApi.listComments(issueId)` with no cursor or limit and then re-invalidates the full thread after common comment actions.
|
||||
|
||||
That means we already have the server-side building blocks for incremental comment loading, but the page is not using them.
|
||||
|
||||
## 4.4 Cache invalidation is broader than necessary
|
||||
|
||||
`invalidateIssue()` in `IssueDetail` invalidates:
|
||||
|
||||
- detail
|
||||
- activity
|
||||
- runs
|
||||
- approvals
|
||||
- feedback votes
|
||||
- attachments
|
||||
- documents
|
||||
- live runs
|
||||
- active run
|
||||
- multiple issue collections
|
||||
- sidebar badges
|
||||
|
||||
That is acceptable for correctness, but it is expensive for perceived speed and makes optimistic work feel less stable because the page keeps re-painting from fresh network results.
|
||||
|
||||
## 4.5 Live run state is fetched twice
|
||||
|
||||
The page polls both:
|
||||
|
||||
- `issues.liveRuns(issueId)` every 3s
|
||||
- `issues.activeRun(issueId)` every 3s
|
||||
|
||||
That is duplicate polling for closely related state.
|
||||
|
||||
## 4.6 Properties panel duplicates more list fetching
|
||||
|
||||
`ui/src/components/IssueProperties.tsx` fetches:
|
||||
|
||||
- session
|
||||
- agents list
|
||||
- projects list
|
||||
- labels
|
||||
- and, when the blocker picker opens, the full company issues list
|
||||
|
||||
The page and panel are each doing their own list work instead of sharing a narrower issue-detail data model.
|
||||
|
||||
## 4.7 The perceived loading UX is too thin
|
||||
|
||||
`IssueDetail` only shows:
|
||||
|
||||
- plain `Loading...` while the main issue query is pending
|
||||
|
||||
After that, many sub-sections can appear empty or incomplete until their own queries resolve. That makes the page feel slower than the raw request times suggest.
|
||||
|
||||
## 5. Recommended Plan
|
||||
|
||||
## 5.1 Phase 1: Fix perceived speed first
|
||||
|
||||
Ship UX changes that make the page feel immediate before deeper backend reshaping:
|
||||
|
||||
- replace the plain `Loading...` state with an issue-detail skeleton
|
||||
- give comments, activity, attachments, and sub-issues their own skeleton/empty/loading states
|
||||
- preserve visible stale data during refetch instead of clearing sections
|
||||
- show explicit pending state for local actions that are already optimistic
|
||||
|
||||
Why first:
|
||||
|
||||
- it improves the user-facing feel immediately
|
||||
- it reduces the chance that later data changes still feel slow because the page flashes blank
|
||||
|
||||
## 5.2 Phase 2: Stop fetching the full company issues list for child issues
|
||||
|
||||
Add `parentId` to the `issuesApi.list(...)` filter type and switch `IssueDetail` to:
|
||||
|
||||
- fetch child issues only
|
||||
- stop loading the full company issue collection on page mount
|
||||
|
||||
This is the highest-confidence narrow win because the server path already exists.
|
||||
|
||||
## 5.3 Phase 3: Convert comments to a bounded + incremental model
|
||||
|
||||
Use the existing server support for:
|
||||
|
||||
- latest comment cursor from heartbeat context or issue bootstrap
|
||||
- incremental fetch with `after`
|
||||
- bounded initial fetch with `limit`
|
||||
|
||||
Suggested behavior:
|
||||
|
||||
- first load: fetch the latest N comments
|
||||
- offer `load earlier` for long threads
|
||||
- after posting or on live updates: append incrementally instead of invalidating the whole thread
|
||||
|
||||
This should address the same performance family as [PAP-254](/PAP/issues/PAP-254).
|
||||
|
||||
## 5.4 Phase 4: Reduce duplicate polling and invalidation
|
||||
|
||||
Tighten the runtime side of the page:
|
||||
|
||||
- collapse `liveRuns` and `activeRun` into one client source if possible
|
||||
- stop invalidating unrelated issue collections after mutations that only affect the current issue
|
||||
- merge server responses into cache where we already have enough information
|
||||
|
||||
Examples:
|
||||
|
||||
- posting a comment should not force a broad company issue list refetch unless list-visible metadata changed
|
||||
- attachment changes should not invalidate approvals or unrelated live-run queries
|
||||
|
||||
## 5.5 Phase 5: Consider an issue-detail bootstrap contract
|
||||
|
||||
If the page is still too chatty after the client fixes, add one tailored bootstrap surface for the issue detail page.
|
||||
|
||||
Potential bootstrap payload:
|
||||
|
||||
- issue core data
|
||||
- child issue summaries
|
||||
- latest comment cursor and recent comment page
|
||||
- live run summary
|
||||
- attachment summaries
|
||||
- approval summaries
|
||||
- any lightweight mention/selector metadata truly needed at first paint
|
||||
|
||||
This should happen after the obvious client overfetch fixes, not before.
|
||||
|
||||
## 6. Concrete Opportunities By Surface
|
||||
|
||||
## 6.1 Issue detail page
|
||||
|
||||
- narrow child issue fetch from full list to `parentId`
|
||||
- stage loading by section instead of all-or-nothing perception
|
||||
- bound initial comments payload
|
||||
- reduce duplicate live-run polling
|
||||
- replace broad invalidation with targeted cache writes
|
||||
|
||||
## 6.2 Issue properties panel
|
||||
|
||||
- reuse page-level agents/projects data where possible
|
||||
- fetch blockers lazily and narrowly
|
||||
- keep local optimistic field updates without broad page invalidation
|
||||
|
||||
## 6.3 Thread/comment UX
|
||||
|
||||
- append optimistic comments directly into the visible thread
|
||||
- keep queued/pending comment state stable during reconciliation
|
||||
- fetch only new comments after the last known cursor
|
||||
|
||||
## 6.4 Cross-app optimistic consistency
|
||||
|
||||
The same standards should apply to:
|
||||
|
||||
- issue archive/unarchive
|
||||
- issue property edits
|
||||
- create issue/sub-issue flows
|
||||
- comment posting
|
||||
- attachment/document actions where the local result is obvious
|
||||
|
||||
## 7. Suggested Execution Order
|
||||
|
||||
1. `PAP-1192`: issue-detail skeletons and staged loading
|
||||
2. add `parentId` support to `ui/src/api/issues.ts` and switch child-issue fetching to a narrow query
|
||||
3. move comments to bounded initial load plus incremental updates
|
||||
4. shrink invalidation and polling scope
|
||||
5. only then decide whether a new issue-detail bootstrap endpoint is still needed
|
||||
|
||||
## 8. Success Criteria
|
||||
|
||||
This inventory is successful if the follow-up implementation makes the issue page behave like this:
|
||||
|
||||
1. navigating to an issue shows a shaped skeleton immediately, not plain text
|
||||
2. the page no longer fetches the full company issue list just to render sub-issues
|
||||
3. long threads do not require full-thread fetches on every load or comment mutation
|
||||
4. local actions feel immediate and do not snap back because of broad invalidation
|
||||
5. the issue page feels faster even when absolute backend timings are already reasonable
|
||||
@@ -0,0 +1,248 @@
|
||||
# Pi Hook Survey
|
||||
|
||||
Status: investigation note
|
||||
Date: 2026-04-07
|
||||
|
||||
## Why this exists
|
||||
|
||||
We were asked to find the hook surfaces exposed by `pi` and `pi-mono`, then decide which ideas transfer cleanly into Paperclip.
|
||||
|
||||
This note is based on direct source inspection of:
|
||||
|
||||
- `badlogic/pi` default branch and `pi2` branch
|
||||
- `badlogic/pi-mono` `packages/coding-agent`
|
||||
- current Paperclip plugin and adapter surfaces in this repo
|
||||
|
||||
## Short answer
|
||||
|
||||
- Current `pi` does not expose a comparable extension hook API. What it exposes today is a JSON event stream from `pi-agent`.
|
||||
- `pi-mono` does expose a real extension hook system. It is broad, typed, and intentionally allows mutation of agent/runtime behavior.
|
||||
- Paperclip should copy only the safe subset:
|
||||
- typed event subscriptions
|
||||
- read-only run lifecycle events
|
||||
- explicit worker lifecycle hooks
|
||||
- plugin-to-plugin events
|
||||
- Paperclip should not copy the dangerous subset:
|
||||
- arbitrary mutation hooks on core control-plane decisions
|
||||
- project-local plugin loading
|
||||
- built-in tool shadowing by name collision
|
||||
|
||||
## What `pi` has today
|
||||
|
||||
Current `badlogic/pi` is primarily a GPU pod manager plus a lightweight agent runner. It does not expose a `pi.on(...)`-style extension API like `pi-mono`.
|
||||
|
||||
The closest thing to hooks is the `pi-agent --json` event stream:
|
||||
|
||||
- `session_start`
|
||||
- `user_message`
|
||||
- `assistant_start`
|
||||
- `assistant_message`
|
||||
- `thinking`
|
||||
- `tool_call`
|
||||
- `tool_result`
|
||||
- `token_usage`
|
||||
- `error`
|
||||
- `interrupted`
|
||||
|
||||
That makes `pi` useful as an event producer, but not as a host for third-party runtime interception.
|
||||
|
||||
## What `pi-mono` has
|
||||
|
||||
`pi-mono` exposes a real extension API through `packages/coding-agent/src/core/extensions/types.ts`.
|
||||
|
||||
### Extension event hooks
|
||||
|
||||
Verified `pi.on(...)` hook names:
|
||||
|
||||
- `resources_discover`
|
||||
- `session_start`
|
||||
- `session_before_switch`
|
||||
- `session_before_fork`
|
||||
- `session_before_compact`
|
||||
- `session_compact`
|
||||
- `session_shutdown`
|
||||
- `session_before_tree`
|
||||
- `session_tree`
|
||||
- `context`
|
||||
- `before_provider_request`
|
||||
- `before_agent_start`
|
||||
- `agent_start`
|
||||
- `agent_end`
|
||||
- `turn_start`
|
||||
- `turn_end`
|
||||
- `message_start`
|
||||
- `message_update`
|
||||
- `message_end`
|
||||
- `tool_execution_start`
|
||||
- `tool_execution_update`
|
||||
- `tool_execution_end`
|
||||
- `model_select`
|
||||
- `tool_call`
|
||||
- `tool_result`
|
||||
- `user_bash`
|
||||
- `input`
|
||||
|
||||
### Other extension surfaces
|
||||
|
||||
`pi-mono` extensions can also:
|
||||
|
||||
- `registerTool(...)`
|
||||
- `registerCommand(...)`
|
||||
- `registerShortcut(...)`
|
||||
- `registerFlag(...)`
|
||||
- `registerMessageRenderer(...)`
|
||||
- `registerProvider(...)`
|
||||
- `unregisterProvider(...)`
|
||||
- use an inter-extension event bus via `pi.events`
|
||||
|
||||
### Important behavior
|
||||
|
||||
`pi-mono` hooks are not just observers. Several can actively mutate behavior:
|
||||
|
||||
- `before_agent_start` can rewrite the effective system prompt and inject messages
|
||||
- `context` can replace the message set before an LLM call
|
||||
- `before_provider_request` can rewrite the serialized provider payload
|
||||
- `tool_call` can mutate tool inputs and block execution
|
||||
- `tool_result` can rewrite tool output
|
||||
- `user_bash` can replace shell execution entirely
|
||||
- `input` can transform or fully handle user input before normal processing
|
||||
|
||||
That is a good fit for a local coding harness. It is not automatically a good fit for a company control plane.
|
||||
|
||||
## What Paperclip already has
|
||||
|
||||
Paperclip already has several hook-like surfaces, but they are much narrower and safer:
|
||||
|
||||
- plugin worker lifecycle hooks such as `setup()` and `onHealth()`
|
||||
- declared webhook endpoints for plugins
|
||||
- scheduled jobs
|
||||
- a typed plugin event bus with filtering and plugin namespacing
|
||||
- adapter runtime hooks for logs/status/usage in the run pipeline
|
||||
|
||||
The plugin event bus is already pointed in the right direction:
|
||||
|
||||
- core domain events can be subscribed to
|
||||
- filters are applied server-side
|
||||
- plugin-emitted events are namespaced under `plugin.<pluginId>.*`
|
||||
- plugins do not override core behavior by name collision
|
||||
|
||||
## What transfers well to Paperclip
|
||||
|
||||
These ideas from `pi-mono` fit Paperclip with little conceptual risk:
|
||||
|
||||
### 1. Read-only run lifecycle subscriptions
|
||||
|
||||
Paperclip should continue exposing run and transcript events to plugins, for example:
|
||||
|
||||
- run started / finished
|
||||
- tool started / finished
|
||||
- usage reported
|
||||
- issue comment created
|
||||
|
||||
This matches Paperclip's control-plane posture: observe, react, automate.
|
||||
|
||||
### 2. Plugin-to-plugin events
|
||||
|
||||
Paperclip already has this. It is worth keeping and extending.
|
||||
|
||||
This is the clean replacement for many ad hoc hook chains.
|
||||
|
||||
### 3. Explicit worker lifecycle hooks
|
||||
|
||||
Paperclip already has `setup()` and `onHealth()`. That is the right shape.
|
||||
|
||||
If more lifecycle is needed, it should stay explicit and host-controlled.
|
||||
|
||||
### 4. Trusted adapter-level prompt/runtime middleware
|
||||
|
||||
Some `pi-mono` ideas do belong in Paperclip, but only inside trusted adapter/runtime code:
|
||||
|
||||
- prompt shaping before a run starts
|
||||
- provider request customization
|
||||
- tool execution wrappers for local coding adapters
|
||||
|
||||
This should be an adapter surface, not a general company plugin surface.
|
||||
|
||||
## What should not transfer directly
|
||||
|
||||
These `pi-mono` capabilities are a bad fit for Paperclip core:
|
||||
|
||||
### 1. Arbitrary mutation hooks on control-plane decisions
|
||||
|
||||
Paperclip should not let general plugins rewrite:
|
||||
|
||||
- issue checkout semantics
|
||||
- approval outcomes
|
||||
- budget enforcement
|
||||
- assignment rules
|
||||
- company scoping
|
||||
|
||||
Those are core invariants.
|
||||
|
||||
### 2. Tool shadowing by name collision
|
||||
|
||||
`pi-mono`'s low-friction override model is great for a personal coding harness.
|
||||
|
||||
Paperclip should keep plugin tools namespaced and non-shadowing.
|
||||
|
||||
### 3. Project-local plugin loading
|
||||
|
||||
Paperclip is an operator-controlled control plane. Repo-local plugin auto-loading would make behavior too implicit and too hard to govern.
|
||||
|
||||
### 4. UI-session-specific hooks as first-class product surface
|
||||
|
||||
Hooks like:
|
||||
|
||||
- `session_before_switch`
|
||||
- `session_before_fork`
|
||||
- `session_before_tree`
|
||||
- `model_select`
|
||||
- `input`
|
||||
- `user_bash`
|
||||
|
||||
are tied to `pi-mono` being an interactive terminal coding harness.
|
||||
|
||||
They do not map directly to Paperclip's board-and-issues model.
|
||||
|
||||
## Recommended Paperclip direction
|
||||
|
||||
If we want a "hooks" story inspired by `pi-mono`, it should split into two layers:
|
||||
|
||||
### Layer 1: safe control-plane plugins
|
||||
|
||||
Allowed surfaces:
|
||||
|
||||
- typed domain event subscriptions
|
||||
- jobs
|
||||
- webhooks
|
||||
- plugin-to-plugin events
|
||||
- UI slots and bridge actions
|
||||
- plugin-owned tools and data endpoints
|
||||
|
||||
Disallowed:
|
||||
|
||||
- mutation of core issue/approval/budget invariants
|
||||
|
||||
### Layer 2: trusted runtime middleware
|
||||
|
||||
For adapters and other trusted runtime packages only:
|
||||
|
||||
- prompt assembly hooks
|
||||
- provider payload hooks
|
||||
- tool execution wrappers
|
||||
- transcript rendering helpers
|
||||
|
||||
This is where the best `pi-mono` runtime ideas belong.
|
||||
|
||||
## Bottom line
|
||||
|
||||
If the question is "what hooks do `pi` and `pi-mono` have?":
|
||||
|
||||
- `pi`: JSON output events, not a general extension hook system
|
||||
- `pi-mono`: a broad extension hook API with 27 named event hooks plus tool/command/provider registration
|
||||
|
||||
If the question is "what works for Paperclip too?":
|
||||
|
||||
- yes: typed event subscriptions, worker lifecycle hooks, namespaced plugin events, read-only run lifecycle events
|
||||
- maybe, but trusted-only: prompt/provider/tool middleware around adapter execution
|
||||
- no: arbitrary mutation hooks on control-plane invariants, project-local plugin loading, tool shadowing
|
||||
@@ -0,0 +1,238 @@
|
||||
# PAP-1231 Agent Browser Process Cleanup Plan
|
||||
|
||||
Status: Proposed
|
||||
Date: 2026-04-08
|
||||
Related issue: `PAP-1231`
|
||||
Audience: Engineering
|
||||
|
||||
## Goal
|
||||
|
||||
Explain why browser processes accumulate during local agent runs and define a cleanup plan that fixes the general process-ownership problem rather than treating `agent-browser` as a one-off.
|
||||
|
||||
## Short answer
|
||||
|
||||
Yes, there is a likely root cause in Paperclip's local execution model.
|
||||
|
||||
Today, heartbeat-run local adapters persist and manage only the top-level spawned PID. Their timeout/cancel path uses direct `child.kill()` semantics. That is weaker than the runtime-service path, which already tracks and terminates whole process groups.
|
||||
|
||||
If Codex, Claude, Cursor, or a skill launched through them starts Chrome or Chromium helpers, Paperclip can lose ownership of those descendants even when it still believes it handled the run correctly.
|
||||
|
||||
## Observed implementation facts
|
||||
|
||||
### 1. Heartbeat-run local adapters track only one PID
|
||||
|
||||
`packages/adapter-utils/src/server-utils.ts`
|
||||
|
||||
- `runChildProcess()` spawns the adapter command and records only `child.pid`
|
||||
- timeout handling sends `SIGTERM` and then `SIGKILL` to the direct child
|
||||
- there is no process-group creation or process-group kill path there today
|
||||
|
||||
`packages/db/src/schema/heartbeat_runs.ts`
|
||||
|
||||
- `heartbeat_runs` stores `process_pid`
|
||||
- there is no persisted `process_group_id`
|
||||
|
||||
`server/src/services/heartbeat.ts`
|
||||
|
||||
- cancellation logic uses the in-memory child handle and calls `child.kill()`
|
||||
- orphaned-run recovery checks whether the recorded direct PID is alive
|
||||
- the recovery model is built around one tracked process, not a descendant tree
|
||||
|
||||
### 2. Workspace runtime already uses stronger ownership
|
||||
|
||||
`server/src/services/workspace-runtime.ts`
|
||||
|
||||
- runtime services are spawned with `detached: process.platform !== "win32"`
|
||||
- the service record stores `processGroupId`
|
||||
- shutdown calls `terminateLocalService()` with group-aware killing
|
||||
|
||||
`server/src/services/local-service-supervisor.ts`
|
||||
|
||||
- `terminateLocalService()` prefers `process.kill(-processGroupId, signal)` on POSIX
|
||||
- it escalates from `SIGTERM` to `SIGKILL`
|
||||
|
||||
This is the clearest internal comparison point: Paperclip already has one local-process subsystem that treats process-group ownership as the right abstraction.
|
||||
|
||||
### 3. The current recovery path explains why leaks would be visible but hard to reason about
|
||||
|
||||
If the direct adapter process exits, hangs, or is cancelled after launching a browser subtree:
|
||||
|
||||
- Paperclip may think it cancelled the run because the parent process is gone
|
||||
- descendant Chrome helpers may still be running
|
||||
- orphan recovery has no persisted process-group identity to reconcile or reap later
|
||||
|
||||
That makes the failure look like an `agent-browser` problem when the more general bug is "executor descendants are not owned strongly enough."
|
||||
|
||||
## Why `agent-browser` makes the problem obvious
|
||||
|
||||
Inference:
|
||||
|
||||
- Chromium is intentionally multi-process
|
||||
- browser automation often leaves a browser process plus renderer, GPU, utility, and crashpad/helper children
|
||||
- skills that open browsers repeatedly amplify the symptom because each run can produce several descendant processes
|
||||
|
||||
So `agent-browser` is probably not the root cause. It is the workload that exposes the weak ownership model fastest.
|
||||
|
||||
## Success condition
|
||||
|
||||
This work is successful when Paperclip can:
|
||||
|
||||
1. start a local adapter run and own the full descendant tree it created
|
||||
2. cancel, timeout, or recover that run without leaving Chrome descendants behind on POSIX
|
||||
3. detect and clean up stale local descendants after server restarts
|
||||
4. expose enough metadata that operators can see which run owns which spawned process tree
|
||||
|
||||
## Non-goals
|
||||
|
||||
Do not:
|
||||
|
||||
- special-case `agent-browser` only
|
||||
- depend on manual `pkill chrome` cleanup as the primary fix
|
||||
- require every skill author to add bespoke browser teardown logic before Paperclip can clean up correctly
|
||||
- change remote/http adapter behavior as part of the first pass
|
||||
|
||||
## Proposed plan
|
||||
|
||||
### Phase 0: reproduce and instrument
|
||||
|
||||
Objective:
|
||||
|
||||
- make the leak measurable from Paperclip's side before changing execution semantics
|
||||
|
||||
Work:
|
||||
|
||||
- add a reproducible local test script or fixture that launches a child process which itself launches descendants and ignores normal parent exit
|
||||
- capture parent PID, descendant PIDs, and run ID in logs during local adapter execution
|
||||
- document current behavior separately for:
|
||||
- normal completion
|
||||
- timeout
|
||||
- explicit cancellation
|
||||
- server restart during run
|
||||
|
||||
Deliverable:
|
||||
|
||||
- one short repro note attached to the implementation issue or child issue
|
||||
|
||||
### Phase 1: give heartbeat-run local adapters process-group ownership
|
||||
|
||||
Objective:
|
||||
|
||||
- align adapter-run execution with the stronger runtime-service model
|
||||
|
||||
Work:
|
||||
|
||||
- update `runChildProcess()` to create a dedicated process group on POSIX
|
||||
- persist both:
|
||||
- direct PID
|
||||
- process-group ID
|
||||
- update the run cancellation and timeout paths to kill the group first, then escalate
|
||||
- keep direct-PID fallback behavior for platforms where group kill is not available
|
||||
|
||||
Likely touched surfaces:
|
||||
|
||||
- `packages/adapter-utils/src/server-utils.ts`
|
||||
- `packages/db/src/schema/heartbeat_runs.ts`
|
||||
- `packages/shared/src/types/heartbeat.ts`
|
||||
- `server/src/services/heartbeat.ts`
|
||||
|
||||
Important design choice:
|
||||
|
||||
- use the same ownership model for all local child-process adapters, not just Codex or Claude
|
||||
|
||||
### Phase 2: make restart recovery group-aware
|
||||
|
||||
Objective:
|
||||
|
||||
- prevent stale descendants from surviving server crashes or restarts indefinitely
|
||||
|
||||
Work:
|
||||
|
||||
- teach orphan reconciliation to inspect the persisted process-group ID, not only the direct PID
|
||||
- if the direct parent is gone but the group still exists, mark the run as detached-orphaned with clearer metadata
|
||||
- decide whether restart recovery should:
|
||||
- adopt the still-running group, or
|
||||
- terminate it as unrecoverable
|
||||
|
||||
Recommendation:
|
||||
|
||||
- for heartbeat runs, prefer terminating unrecoverable orphan groups rather than adopting them unless we can prove the adapter session remains safe and observable
|
||||
|
||||
Reason:
|
||||
|
||||
- runtime services are long-lived and adoptable
|
||||
- heartbeat runs are task executions with stricter audit and cancellation semantics
|
||||
|
||||
### Phase 3: add operator-visible cleanup tools
|
||||
|
||||
Objective:
|
||||
|
||||
- make the system diagnosable when ownership still fails
|
||||
|
||||
Work:
|
||||
|
||||
- surface the tracked process metadata in run details or debug endpoints
|
||||
- add a control-plane cleanup action or CLI utility for stale local run processes owned by Paperclip
|
||||
- scope cleanup by run/agent/company instead of broad browser-name matching
|
||||
|
||||
This should replace ad hoc scripts as the general-purpose escape hatch.
|
||||
|
||||
### Phase 4: cover platform and regression cases
|
||||
|
||||
Objective:
|
||||
|
||||
- keep the fix from regressing and define platform behavior explicitly
|
||||
|
||||
Tests to add:
|
||||
|
||||
- unit tests around process-group-aware cancellation in adapter execution utilities
|
||||
- heartbeat recovery tests for:
|
||||
- surviving descendant tree after parent loss
|
||||
- timeout cleanup
|
||||
- cancellation cleanup
|
||||
- platform-conditional behavior notes for Windows, where negative-PID group kill does not apply
|
||||
|
||||
## Recommended first implementation slice
|
||||
|
||||
The first shipping slice should be narrow:
|
||||
|
||||
1. introduce process-group ownership for local heartbeat-run adapters on POSIX
|
||||
2. persist group metadata on `heartbeat_runs`
|
||||
3. switch timeout/cancel paths from direct-child kill to group kill
|
||||
4. add one regression test that proves descendants die with the parent run
|
||||
|
||||
That should address the main Chrome accumulation path without taking on the full restart-recovery design in the same patch.
|
||||
|
||||
## Risks
|
||||
|
||||
### 1. Over-killing unrelated processes
|
||||
|
||||
If process-group boundaries are created incorrectly, cleanup could terminate more than the run owns.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- create a fresh process group only for the spawned adapter command
|
||||
- persist and target that exact group
|
||||
|
||||
### 2. Cross-platform differences
|
||||
|
||||
Windows does not support the POSIX negative-PID kill pattern used elsewhere in the repo.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- ship POSIX-first
|
||||
- keep direct-child fallback on Windows
|
||||
- document Windows as partial until job-object or equivalent handling is designed
|
||||
|
||||
### 3. Session recovery complexity
|
||||
|
||||
Adopting a still-running orphaned group may look attractive but can break observability if stdout/stderr pipes are already gone.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- default to deterministic cleanup for heartbeat runs unless adoption is explicitly proven safe
|
||||
|
||||
## Recommendation
|
||||
|
||||
Treat this as a Paperclip executor ownership bug, not an `agent-browser` bug.
|
||||
|
||||
`agent-browser` should remain a useful repro case, but the implementation should be shared across all local child-process adapters so any descendant process tree spawned by Codex, Claude, Cursor, Gemini, Pi, or OpenCode is owned and cleaned up consistently.
|
||||
@@ -0,0 +1,261 @@
|
||||
# PAP-1229 Agent OS Follow-up Plan
|
||||
|
||||
Date: 2026-04-08
|
||||
Related issue: `PAP-1229`
|
||||
Companion analysis: `doc/plans/2026-04-08-agent-os-technical-report.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Turn the `agent-os` research into a low-risk Paperclip execution plan that preserves Paperclip's control-plane model while testing the few runtime ideas that appear worth adopting.
|
||||
|
||||
## Decision summary
|
||||
|
||||
Paperclip should not absorb `agent-os` as a product model or orchestration layer.
|
||||
|
||||
Paperclip should evaluate `agent-os` in three narrow areas:
|
||||
|
||||
1. optional agent runtime for selected local adapters
|
||||
2. capability-based runtime permission vocabulary
|
||||
3. snapshot-backed disposable execution roots
|
||||
|
||||
Everything else should stay out of scope unless those three experiments produce strong evidence.
|
||||
|
||||
## Success condition
|
||||
|
||||
This work is successful when Paperclip has:
|
||||
|
||||
- a clear yes/no answer on whether `agent-os` is worth supporting as an execution substrate
|
||||
- a concrete adapter/runtime experiment with measurable results
|
||||
- a proposed runtime capability model that fits current Paperclip adapters
|
||||
- a clear decision on whether snapshot-backed execution roots are worth integrating
|
||||
|
||||
## Non-goals
|
||||
|
||||
Do not:
|
||||
|
||||
- replace Paperclip heartbeats, issues, comments, approvals, or budgets with `agent-os` primitives
|
||||
- introduce Rust/sidecar requirements for all local execution paths
|
||||
- migrate all adapters at once
|
||||
- add runtime workflow/queue abstractions to Paperclip core
|
||||
|
||||
## Existing Paperclip integration points
|
||||
|
||||
The plan should stay anchored to these existing surfaces:
|
||||
|
||||
- `packages/adapter-utils/src/types.ts`
|
||||
- adapter contract, runtime service reporting, session metadata, and capability normalization targets
|
||||
- `server/src/services/heartbeat.ts`
|
||||
- execution entry point, log capture, issue comment summaries, and cost reporting
|
||||
- `server/src/services/execution-workspaces.ts`
|
||||
- current workspace lifecycle and git-oriented cleanup/readiness model
|
||||
- `server/src/services/plugin-loader.ts`
|
||||
- typed host capability boundary and extension loading patterns
|
||||
- local adapter implementations in `packages/adapters/*/src/server/`
|
||||
- current execution behavior to compare against an `agent-os`-backed path
|
||||
|
||||
## Phase plan
|
||||
|
||||
### Phase 0: constraints and experiment design
|
||||
|
||||
Objective:
|
||||
|
||||
- make the evaluation falsifiable before writing integration code
|
||||
|
||||
Deliverables:
|
||||
|
||||
- short experiment brief added to this document or a child issue
|
||||
- chosen first runtime target: `pi_local` or `opencode_local`
|
||||
- baseline metrics definition
|
||||
|
||||
Questions to lock down:
|
||||
|
||||
- what exact developer experience should improve
|
||||
- what security/isolation property we expect to gain
|
||||
- what failure modes are unacceptable
|
||||
- whether the prototype is adapter-only or a deeper internal runtime abstraction spike
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- a single first target chosen
|
||||
- measurable comparison criteria agreed on
|
||||
|
||||
Recommended metrics:
|
||||
|
||||
- cold start latency
|
||||
- session resume reliability across heartbeats
|
||||
- transcript/log quality
|
||||
- implementation complexity
|
||||
- operational complexity on local dev machines
|
||||
|
||||
### Phase 1: `agentos_local` spike
|
||||
|
||||
Objective:
|
||||
|
||||
- prove that Paperclip can drive one local agent through an `agent-os` runtime without breaking heartbeat semantics
|
||||
|
||||
Suggested scope:
|
||||
|
||||
- implement a new experimental adapter, `agentos_local`, or a feature-flagged runtime path under one existing adapter
|
||||
- start with `pi_local` or `opencode_local`
|
||||
- keep Paperclip's existing heartbeat, issue, workspace, and comment flow authoritative
|
||||
|
||||
Minimum implementation shape:
|
||||
|
||||
- adapter accepts model/runtime config
|
||||
- `server/src/services/heartbeat.ts` still owns run lifecycle
|
||||
- execution result still maps into existing `AdapterExecutionResult`
|
||||
- session state still fits current `sessionParams` / `sessionDisplayId` flow
|
||||
|
||||
What to verify:
|
||||
|
||||
- checkout and heartbeat flow still work end to end
|
||||
- resume across multiple heartbeats works
|
||||
- logs/transcripts remain readable in the UI
|
||||
- failure paths surface cleanly in issue comments and run logs
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- one agent type can run reliably through the new path
|
||||
- documented comparison against the existing local adapter path
|
||||
- explicit recommendation: continue, pause, or abandon
|
||||
|
||||
### Phase 2: capability-based runtime permissions
|
||||
|
||||
Objective:
|
||||
|
||||
- introduce a Paperclip-native capability vocabulary without coupling the product to `agent-os`
|
||||
|
||||
Suggested scope:
|
||||
|
||||
- extend adapter config schema vocabulary for runtime permissions
|
||||
- prototype normalized capabilities such as:
|
||||
- `fs.read`
|
||||
- `fs.write`
|
||||
- `network.fetch`
|
||||
- `network.listen`
|
||||
- `process.spawn`
|
||||
- `env.read`
|
||||
|
||||
Integration targets:
|
||||
|
||||
- `packages/adapter-utils/src/types.ts`
|
||||
- adapter config-schema support
|
||||
- server-side runtime config validation
|
||||
- future board-facing UI for permissions, if needed
|
||||
|
||||
What to avoid:
|
||||
|
||||
- building a full human policy UI before the vocabulary is proven useful
|
||||
- forcing every adapter to implement capability enforcement immediately
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- documented capability schema
|
||||
- one adapter path using it meaningfully
|
||||
- clear compatibility story for non-`agent-os` adapters
|
||||
|
||||
### Phase 3: snapshot-backed execution root experiment
|
||||
|
||||
Objective:
|
||||
|
||||
- determine whether a layered/snapshotted root model improves some Paperclip workloads
|
||||
|
||||
Suggested scope:
|
||||
|
||||
- evaluate it only for disposable or non-repo-heavy tasks first
|
||||
- keep git worktree-based repo editing as the default for codebase tasks
|
||||
|
||||
Promising use cases:
|
||||
|
||||
- routine-style runs
|
||||
- ephemeral preview/test environments
|
||||
- isolated document/artifact generation
|
||||
- tasks that do not need full git history or branch semantics
|
||||
|
||||
Integration targets:
|
||||
|
||||
- `server/src/services/execution-workspaces.ts`
|
||||
- workspace realization paths called from `server/src/services/heartbeat.ts`
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- clear statement on which workload classes benefit
|
||||
- clear statement on which workloads should stay on worktrees
|
||||
- go/no-go decision for broader implementation
|
||||
|
||||
### Phase 4: typed host tool evaluation
|
||||
|
||||
Objective:
|
||||
|
||||
- identify where Paperclip should prefer explicit typed tools over ambient shell access
|
||||
|
||||
Suggested scope:
|
||||
|
||||
- compare `agent-os` host-toolkit ideas with existing plugin and runtime-service surfaces
|
||||
- choose 1-2 sensitive operations that should become typed tools
|
||||
|
||||
Good candidates:
|
||||
|
||||
- git metadata/status inspection
|
||||
- runtime service inspection
|
||||
- deployment/preview status retrieval
|
||||
- generated artifact publishing
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- one concrete proposal for typed-tool adoption in Paperclip
|
||||
- clear statement on whether this belongs in plugins, adapters, or core services
|
||||
|
||||
## Recommended sequencing
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Phase 0
|
||||
2. Phase 1
|
||||
3. Phase 2
|
||||
4. Phase 3
|
||||
5. Phase 4
|
||||
|
||||
Reasoning:
|
||||
|
||||
- Phase 1 is the fastest way to invalidate or validate the entire `agent-os` direction
|
||||
- Phase 2 is valuable even if Phase 1 is abandoned
|
||||
- Phase 3 should wait until there is confidence that the runtime approach is operationally worthwhile
|
||||
- Phase 4 is useful independently but should be informed by what Phase 1 and Phase 2 expose
|
||||
|
||||
## Risks
|
||||
|
||||
### Technical risk
|
||||
|
||||
- `agent-os` introduces Rust sidecar and packaging complexity that may outweigh runtime benefits
|
||||
|
||||
### Product risk
|
||||
|
||||
- runtime experimentation could blur the boundary between Paperclip as control plane and Paperclip as execution platform
|
||||
|
||||
### Integration risk
|
||||
|
||||
- session semantics, log formatting, and failure behavior may degrade relative to current local adapters
|
||||
|
||||
### Scope risk
|
||||
|
||||
- a small runtime spike could expand into an adapter-system rewrite if not kept tightly bounded
|
||||
|
||||
## Guardrails
|
||||
|
||||
To keep this effort controlled:
|
||||
|
||||
- keep all experiments behind a clearly experimental adapter or feature flag
|
||||
- do not change issue/comment/approval/budget semantics to suit the runtime
|
||||
- measure against current local adapters instead of judging in isolation
|
||||
- stop after Phase 1 if the operational burden is already clearly too high
|
||||
|
||||
## Proposed next action
|
||||
|
||||
The next concrete action should be a small implementation spike issue:
|
||||
|
||||
- title: `Prototype experimental agentos_local runtime for one local adapter`
|
||||
- target adapter: `opencode_local` unless `pi_local` is materially easier
|
||||
- expected output: code spike, short verification notes, and a continue/stop recommendation
|
||||
|
||||
If leadership wants planning only and no spike yet, this document is the handoff artifact for that decision.
|
||||
@@ -0,0 +1,397 @@
|
||||
# Agent OS Technical Report for Paperclip
|
||||
|
||||
Date: 2026-04-08
|
||||
Analyzed upstream: `rivet-dev/agent-os` at commit `0063cdccd1dcb1c8e211670cd05482d70d26a5c4` (`0063cdc`), dated 2026-04-06
|
||||
|
||||
## Executive summary
|
||||
|
||||
`agent-os` is not a competitor to Paperclip's core product. It is an execution substrate: an embedded, VM-like runtime for agents, tools, filesystems, and session orchestration. Paperclip is a control plane: company scoping, task hierarchy, approvals, budgets, activity logs, workspaces, and governance.
|
||||
|
||||
The strongest takeaway is not "copy agent-os wholesale." The strongest takeaway is that Paperclip could selectively use its runtime ideas to improve local agent execution safety, reproducibility, and portability while keeping all company/task/governance logic in Paperclip.
|
||||
|
||||
My recommendation is:
|
||||
|
||||
1. Do not merge agent-os concepts into the Paperclip core product model.
|
||||
2. Do evaluate an optional `agentos_local` execution adapter or internal runtime experiment.
|
||||
3. Borrow a few design patterns aggressively:
|
||||
- layered/snapshotted execution filesystems
|
||||
- explicit capability-based runtime permissions
|
||||
- a better host-tools bridge for controlled tool execution
|
||||
- a normalized session capability model for agent adapters
|
||||
4. Do not import its workflow/cron/queue abstractions into Paperclip core until they are reconciled with Paperclip's issue/comment/governance model.
|
||||
|
||||
## What agent-os actually is
|
||||
|
||||
From the repo layout and implementation, `agent-os` is a mixed TypeScript/Rust system that provides:
|
||||
|
||||
- an `AgentOs` TypeScript API for creating isolated agent VMs
|
||||
- a Rust kernel/sidecar that virtualizes filesystem, processes, PTYs, pipes, permissions, and networking
|
||||
- an ACP-based session model for agent runtimes such as Pi, OpenCode, and Claude-style adapters
|
||||
- a registry of WASM command packages and mount plugins
|
||||
- optional host toolkits, cron scheduling, and filesystem mounts
|
||||
|
||||
The repo is substantial already:
|
||||
|
||||
- monorepo with `packages/`, `crates/`, and `registry/`
|
||||
- roughly 1,200 files just across `packages/`, `crates/`, and `registry/`
|
||||
- mixed implementation model: TypeScript public API plus Rust kernel/sidecar internals
|
||||
|
||||
## Architecture notes
|
||||
|
||||
### 1. Public runtime surface
|
||||
|
||||
The main API lives in `packages/core/src/agent-os.ts` and exports an `AgentOs` class with methods such as:
|
||||
|
||||
- `create()`
|
||||
- `createSession()`
|
||||
- `prompt()`
|
||||
- `exec()`
|
||||
- `spawn()`
|
||||
- `snapshotRootFilesystem()`
|
||||
- cron scheduling helpers
|
||||
|
||||
This is an execution API, not a coordination API.
|
||||
|
||||
### 2. Virtualized kernel model
|
||||
|
||||
The kernel is implemented in Rust under `crates/kernel/src/`. It models:
|
||||
|
||||
- virtual filesystem
|
||||
- process table
|
||||
- PTYs and pipes
|
||||
- resource accounting
|
||||
- permissioned filesystem access
|
||||
- network permission checks
|
||||
|
||||
That gives `agent-os` a much stronger isolation story than Paperclip's current "launch a host CLI in a workspace" local adapter approach.
|
||||
|
||||
### 3. Layered filesystem and snapshots
|
||||
|
||||
The filesystem design is one of the most reusable ideas. `agent-os` uses:
|
||||
|
||||
- a bundled base filesystem
|
||||
- a writable overlay
|
||||
- optional mounted filesystems
|
||||
- snapshot export/import for reusing root states
|
||||
|
||||
This is cleaner than treating every execution workspace as a mutable checkout plus ad hoc cleanup. It enables reproducible starting states and cheap isolation.
|
||||
|
||||
### 4. Capability-based permissions
|
||||
|
||||
The kernel-level permission vocabulary is strong and concrete:
|
||||
|
||||
- filesystem operations
|
||||
- network operations
|
||||
- child-process execution
|
||||
- environment access
|
||||
|
||||
The Rust kernel defaults are deny-oriented, but the high-level JS API currently serializes permissive defaults unless the caller provides a policy. That is an important nuance: the primitive is security-minded, but the product surface is still convenience-first.
|
||||
|
||||
### 5. Host-tools bridge
|
||||
|
||||
`agent-os` exposes host-side tools via a toolkit abstraction (`hostTool`, `toolKit`) and a local RPC bridge. This is a strong pattern because it gives the agent explicit, typed tools rather than ambient shell access to everything on the host.
|
||||
|
||||
### 6. ACP session abstraction
|
||||
|
||||
The session model is more uniform than most agent wrappers. It includes:
|
||||
|
||||
- capabilities
|
||||
- mode/config options
|
||||
- permission requests
|
||||
- sequenced session events
|
||||
- JSON-RPC transport through ACP adapters
|
||||
|
||||
This is directly relevant to Paperclip because our adapter layer still normalizes each CLI agent in a fairly bespoke way.
|
||||
|
||||
## Paperclip anchor points
|
||||
|
||||
The most relevant current Paperclip surfaces for any future `agent-os` integration are:
|
||||
|
||||
- `packages/adapter-utils/src/types.ts`
|
||||
- shared adapter contract, session metadata, runtime service reporting, environment tests, and optional `detectModel()`
|
||||
- `server/src/services/heartbeat.ts`
|
||||
- heartbeat execution, adapter invocation, cost capture, workspace realization, and issue-comment summaries
|
||||
- `server/src/services/execution-workspaces.ts`
|
||||
- execution workspace lifecycle and git readiness/cleanup logic
|
||||
- `server/src/services/plugin-loader.ts`
|
||||
- dynamic plugin activation, host capability boundaries, and runtime extension loading
|
||||
- local adapters such as `packages/adapters/codex-local/src/server/execute.ts` and peers
|
||||
- current host-CLI execution model that an `agent-os` runtime experiment would complement or replace for selected agents
|
||||
|
||||
## What Paperclip can learn from it
|
||||
|
||||
### 1. A safer local execution substrate
|
||||
|
||||
Paperclip's local adapters currently run host CLIs in managed workspaces and rely on adapter-specific behavior plus process-level controls. That is pragmatic, but weakly isolated.
|
||||
|
||||
`agent-os` shows a path toward:
|
||||
|
||||
- running local agent tooling in a constrained runtime
|
||||
- applying explicit network/filesystem/env policies
|
||||
- reducing accidental host leakage
|
||||
- making adapter behavior more portable across machines
|
||||
|
||||
Best use in Paperclip:
|
||||
|
||||
- as an optional runtime beneath local adapters
|
||||
- or as a new adapter family for agents that can run inside ACP-compatible `agent-os` sessions
|
||||
|
||||
This fits Paperclip because it improves execution safety without changing the control-plane model.
|
||||
|
||||
### 2. Snapshotted execution roots instead of only mutable workspaces
|
||||
|
||||
Paperclip already has strong execution-workspace concepts, but they are repo/worktree-centric. `agent-os` adds a stronger "start from known lower layers, write into a disposable upper layer" model.
|
||||
|
||||
That could improve:
|
||||
|
||||
- reproducible issue starts
|
||||
- disposable task sandboxes
|
||||
- faster reset/cleanup
|
||||
- "resume from snapshot" behavior for recurring routines
|
||||
- safe preview environments for risky agent operations
|
||||
|
||||
This is especially interesting for tasks that do not need a full git worktree.
|
||||
|
||||
### 3. A capability vocabulary for runtime governance
|
||||
|
||||
Paperclip has governance at the company/task level:
|
||||
|
||||
- approvals
|
||||
- budgets
|
||||
- activity logs
|
||||
- actor permissions
|
||||
- company scoping
|
||||
|
||||
It has less structure at the runtime capability level. `agent-os` offers a clear vocabulary that Paperclip could adopt even without adopting the runtime itself:
|
||||
|
||||
- `fs.read`, `fs.write`, `fs.mount_sensitive`
|
||||
- `network.fetch`, `network.http`, `network.listen`, `network.dns`
|
||||
- child process execution
|
||||
- env access
|
||||
|
||||
That vocabulary would improve:
|
||||
|
||||
- adapter configuration schemas
|
||||
- policy UIs
|
||||
- execution review surfaces
|
||||
- future approval gates for governed actions
|
||||
|
||||
### 4. Typed host tools instead of shelling out for everything
|
||||
|
||||
Paperclip's plugin system and adapters already have the beginnings of a controlled extension surface. `agent-os` reinforces the value of exposing capabilities as typed tools rather than raw shell access.
|
||||
|
||||
Concrete Paperclip uses:
|
||||
|
||||
- board-approved toolkits for sensitive operations
|
||||
- company-scoped service tools
|
||||
- plugin-defined tools with explicit schemas
|
||||
- safer execution for common actions like git metadata inspection, preview lookups, deployment status checks, or document generation
|
||||
|
||||
This aligns well with Paperclip's governance story.
|
||||
|
||||
### 5. Better adapter normalization around sessions and capabilities
|
||||
|
||||
Paperclip's adapter contract already supports execution results, session params, environment tests, skill syncing, quota windows, and optional `detectModel()`. But much of the per-agent behavior is still adapter-specific.
|
||||
|
||||
`agent-os` suggests a cleaner normalization target:
|
||||
|
||||
- a standard capability map
|
||||
- a consistent event stream model
|
||||
- explicit mode/config surfaces
|
||||
- explicit permission request semantics
|
||||
|
||||
Paperclip does not need ACP everywhere, but it would benefit from a more formal internal session capability model inspired by this.
|
||||
|
||||
### 6. On-demand heavy sandbox escalation
|
||||
|
||||
One of the best architectural choices in `agent-os` is that it does not pretend every workload fits the lightweight runtime. It has a sandbox extension for workloads that need a fuller environment.
|
||||
|
||||
Paperclip can adopt that philosophy directly:
|
||||
|
||||
- lightweight execution by default
|
||||
- escalate to full worktree / container / remote sandbox only when needed
|
||||
- keep the escalation explicit in the issue/run model
|
||||
|
||||
That is better than forcing all tasks into the heaviest environment up front.
|
||||
|
||||
## What does not fit Paperclip well
|
||||
|
||||
### 1. Its built-in orchestration primitives overlap the wrong layer
|
||||
|
||||
`agent-os` includes cron/session/workflow style primitives inside the runtime package. Paperclip already has higher-level orchestration concepts:
|
||||
|
||||
- issues/comments
|
||||
- heartbeat runs
|
||||
- approvals
|
||||
- company/org structure
|
||||
- execution workspaces
|
||||
- budget enforcement
|
||||
|
||||
If Paperclip copied `agent-os` cron/workflow/queue ideas directly into core, we would likely duplicate orchestration across two layers. That would blur ownership and make debugging harder.
|
||||
|
||||
Paperclip should keep orchestration authoritative at the control-plane layer.
|
||||
|
||||
### 2. It is not company-scoped or governance-native
|
||||
|
||||
`agent-os` is runtime-first, not company-first. It has no native concepts for:
|
||||
|
||||
- company boundaries
|
||||
- board/operator actor types
|
||||
- audit logs for business actions
|
||||
- issue hierarchy
|
||||
- approval routing
|
||||
- budget hard-stop behavior
|
||||
|
||||
Those are Paperclip's differentiators. They should not be displaced by runtime abstractions.
|
||||
|
||||
### 3. It introduces meaningful implementation complexity
|
||||
|
||||
Adopting `agent-os` deeply would add:
|
||||
|
||||
- Rust build/runtime complexity
|
||||
- sidecar lifecycle management
|
||||
- new failure modes across JS/Rust boundaries
|
||||
- more packaging and platform compatibility work
|
||||
- another abstraction layer for debugging already-complex local adapters
|
||||
|
||||
This is justified only if we want stronger local isolation or portability. It is not justified as a general refactor.
|
||||
|
||||
### 4. Its security model is not a drop-in governance solution
|
||||
|
||||
The permission model is good, but it is low-level. Paperclip would still need to answer:
|
||||
|
||||
- who can authorize a capability
|
||||
- how approval decisions are logged
|
||||
- how policies are scoped by company/project/issue/agent
|
||||
- how runtime permissions interact with budgets and task status
|
||||
|
||||
In other words, `agent-os` can supply enforcement primitives, not the control policy system itself.
|
||||
|
||||
### 5. The agent compatibility story is still selective
|
||||
|
||||
The repo is explicit that some runtimes are planned, partial, or still being adapted. In practice this means:
|
||||
|
||||
- good ideas for ACP-native or compatible agents
|
||||
- less certainty for every CLI agent we support today
|
||||
- real integration work for Codex/Cursor/Gemini-style Paperclip adapters
|
||||
|
||||
So the main near-term value is not universal replacement. It is selective use where compatibility is strong.
|
||||
|
||||
## Concrete recommendations for Paperclip
|
||||
|
||||
### Recommendation A: prototype an optional `agentos_local` adapter
|
||||
|
||||
This is the highest-value experiment.
|
||||
|
||||
Goal:
|
||||
|
||||
- run one supported agent type inside `agent-os`
|
||||
- keep Paperclip heartbeat/task/workspace/budget logic unchanged
|
||||
- evaluate startup time, isolation, transcript quality, and operational complexity
|
||||
|
||||
Good first target:
|
||||
|
||||
- `pi_local` or `opencode_local`
|
||||
|
||||
Why not start with Codex:
|
||||
|
||||
- Paperclip's Codex adapter is already important and carries repo-specific behavior
|
||||
- `agent-os`'s Codex story is present in the registry/docs, but the safest path is to validate the runtime on a less central adapter first
|
||||
|
||||
Success criteria:
|
||||
|
||||
- heartbeat can invoke the adapter reliably
|
||||
- session resume works across heartbeats
|
||||
- Paperclip still records logs, summaries, cost metadata, and issue comments normally
|
||||
- runtime permissions can be configured without breaking common tasks
|
||||
|
||||
### Recommendation B: adopt capability vocabulary into adapter configs
|
||||
|
||||
Even without using `agent-os`, Paperclip should consider standardizing adapter/runtime permissions around a vocabulary like:
|
||||
|
||||
- filesystem
|
||||
- network
|
||||
- subprocess/tool execution
|
||||
- environment access
|
||||
|
||||
This would improve:
|
||||
|
||||
- schema-driven adapter UIs
|
||||
- future approvals
|
||||
- observability
|
||||
- policy portability across adapters
|
||||
|
||||
### Recommendation C: explore snapshot-backed execution workspaces
|
||||
|
||||
Paperclip should evaluate whether some execution workspaces can be backed by:
|
||||
|
||||
- a reusable lower snapshot
|
||||
- a disposable upper layer
|
||||
- optional mounts for project data or artifacts
|
||||
|
||||
This is most valuable for:
|
||||
|
||||
- non-repo tasks
|
||||
- repeatable routines
|
||||
- preview/test environments
|
||||
- isolation-heavy local execution
|
||||
|
||||
It is less urgent for full repo editing flows that already benefit from git worktrees.
|
||||
|
||||
### Recommendation D: strengthen typed tool surfaces
|
||||
|
||||
Paperclip plugins and adapters should continue moving toward explicit typed tools over ad hoc shell access. `agent-os` confirms that this is the right direction.
|
||||
|
||||
This is a good fit for:
|
||||
|
||||
- plugin tools
|
||||
- workspace runtime services
|
||||
- governed operations that need approval or auditability
|
||||
|
||||
### Recommendation E: do not import runtime-level workflows into Paperclip core
|
||||
|
||||
Paperclip should not copy `agent-os` cron/workflow/queue concepts into core orchestration yet.
|
||||
|
||||
If we want them later, they must map cleanly onto:
|
||||
|
||||
- issues
|
||||
- comments
|
||||
- heartbeats
|
||||
- approvals
|
||||
- budgets
|
||||
- activity logs
|
||||
|
||||
Without that mapping, they would create a second orchestration system inside the product.
|
||||
|
||||
## A practical integration map
|
||||
|
||||
### Best near-term fits
|
||||
|
||||
- optional local adapter runtime
|
||||
- runtime capability schema
|
||||
- typed host-tool ideas for plugins/adapters
|
||||
- snapshot ideas for disposable execution roots
|
||||
|
||||
### Medium-term fits
|
||||
|
||||
- stronger session capability normalization across adapters
|
||||
- policy-aware runtime permission UI
|
||||
- selective ACP-inspired event normalization
|
||||
|
||||
### Poor fits right now
|
||||
|
||||
- moving Paperclip orchestration into agent-os workflows
|
||||
- replacing company/task/governance models with runtime constructs
|
||||
- making Rust sidecars a mandatory dependency for all local execution
|
||||
|
||||
## Bottom line
|
||||
|
||||
`agent-os` is useful to Paperclip as an execution technology reference, not as a product model.
|
||||
|
||||
Paperclip should treat it the same way it treats sandboxes or agent CLIs:
|
||||
|
||||
- execution substrate underneath the control plane
|
||||
- optional where the tradeoff is worth it
|
||||
- never the source of truth for company/task/governance state
|
||||
|
||||
If we do one thing from this report, it should be a narrowly scoped `agentos_local` experiment plus a design pass on capability-based runtime permissions. Those two ideas have the best upside and the lowest architectural risk.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runChildProcess } from "./server-utils.js";
|
||||
|
||||
describe("runChildProcess", () => {
|
||||
it("waits for onSpawn before sending stdin to the child", async () => {
|
||||
const spawnDelayMs = 150;
|
||||
const startedAt = Date.now();
|
||||
let onSpawnCompletedAt = 0;
|
||||
|
||||
const result = await runChildProcess(
|
||||
randomUUID(),
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
"let data='';process.stdin.setEncoding('utf8');process.stdin.on('data',chunk=>data+=chunk);process.stdin.on('end',()=>process.stdout.write(data));",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
stdin: "hello from stdin",
|
||||
timeoutSec: 5,
|
||||
graceSec: 1,
|
||||
onLog: async () => {},
|
||||
onSpawn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, spawnDelayMs));
|
||||
onSpawnCompletedAt = Date.now();
|
||||
},
|
||||
},
|
||||
);
|
||||
const finishedAt = Date.now();
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("hello from stdin");
|
||||
expect(onSpawnCompletedAt).toBeGreaterThanOrEqual(startedAt + spawnDelayMs);
|
||||
expect(finishedAt - startedAt).toBeGreaterThanOrEqual(spawnDelayMs);
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,22 @@ type PaperclipWakeIssue = {
|
||||
priority: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakeExecutionPrincipal = {
|
||||
type: "agent" | "user" | null;
|
||||
agentId: string | null;
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakeExecutionStage = {
|
||||
wakeRole: "reviewer" | "approver" | "executor" | null;
|
||||
stageId: string | null;
|
||||
stageType: string | null;
|
||||
currentParticipant: PaperclipWakeExecutionPrincipal | null;
|
||||
returnAssignee: PaperclipWakeExecutionPrincipal | null;
|
||||
lastDecisionOutcome: string | null;
|
||||
allowedActions: string[];
|
||||
};
|
||||
|
||||
type PaperclipWakeComment = {
|
||||
id: string | null;
|
||||
issueId: string | null;
|
||||
@@ -214,6 +230,7 @@ type PaperclipWakeComment = {
|
||||
type PaperclipWakePayload = {
|
||||
reason: string | null;
|
||||
issue: PaperclipWakeIssue | null;
|
||||
executionStage: PaperclipWakeExecutionStage | null;
|
||||
commentIds: string[];
|
||||
latestCommentId: string | null;
|
||||
comments: PaperclipWakeComment[];
|
||||
@@ -257,6 +274,50 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null {
|
||||
const principal = parseObject(value);
|
||||
const typeRaw = asString(principal.type, "").trim().toLowerCase();
|
||||
if (typeRaw !== "agent" && typeRaw !== "user") return null;
|
||||
return {
|
||||
type: typeRaw,
|
||||
agentId: asString(principal.agentId, "").trim() || null,
|
||||
userId: asString(principal.userId, "").trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeExecutionStage(value: unknown): PaperclipWakeExecutionStage | null {
|
||||
const stage = parseObject(value);
|
||||
const wakeRoleRaw = asString(stage.wakeRole, "").trim().toLowerCase();
|
||||
const wakeRole =
|
||||
wakeRoleRaw === "reviewer" || wakeRoleRaw === "approver" || wakeRoleRaw === "executor"
|
||||
? wakeRoleRaw
|
||||
: null;
|
||||
const allowedActions = Array.isArray(stage.allowedActions)
|
||||
? stage.allowedActions
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
.map((entry) => entry.trim())
|
||||
: [];
|
||||
const currentParticipant = normalizePaperclipWakeExecutionPrincipal(stage.currentParticipant);
|
||||
const returnAssignee = normalizePaperclipWakeExecutionPrincipal(stage.returnAssignee);
|
||||
const stageId = asString(stage.stageId, "").trim() || null;
|
||||
const stageType = asString(stage.stageType, "").trim() || null;
|
||||
const lastDecisionOutcome = asString(stage.lastDecisionOutcome, "").trim() || null;
|
||||
|
||||
if (!wakeRole && !stageId && !stageType && !currentParticipant && !returnAssignee && !lastDecisionOutcome && allowedActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
wakeRole,
|
||||
stageId,
|
||||
stageType,
|
||||
currentParticipant,
|
||||
returnAssignee,
|
||||
lastDecisionOutcome,
|
||||
allowedActions,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayload | null {
|
||||
const payload = parseObject(value);
|
||||
const comments = Array.isArray(payload.comments)
|
||||
@@ -270,12 +331,16 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
.map((entry) => entry.trim())
|
||||
: [];
|
||||
const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage);
|
||||
|
||||
if (comments.length === 0 && commentIds.length === 0) return null;
|
||||
if (comments.length === 0 && commentIds.length === 0 && !executionStage && !normalizePaperclipWakeIssue(payload.issue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reason: asString(payload.reason, "").trim() || null,
|
||||
issue: normalizePaperclipWakeIssue(payload.issue),
|
||||
executionStage,
|
||||
commentIds,
|
||||
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
||||
comments,
|
||||
@@ -300,6 +365,12 @@ export function renderPaperclipWakePrompt(
|
||||
const normalized = normalizePaperclipWakePayload(value);
|
||||
if (!normalized) return "";
|
||||
const resumedSession = options.resumedSession === true;
|
||||
const executionStage = normalized.executionStage;
|
||||
const principalLabel = (principal: PaperclipWakeExecutionPrincipal | null) => {
|
||||
if (!principal || !principal.type) return "unknown";
|
||||
if (principal.type === "agent") return principal.agentId ? `agent ${principal.agentId}` : "agent";
|
||||
return principal.userId ? `user ${principal.userId}` : "user";
|
||||
};
|
||||
|
||||
const lines = resumedSession
|
||||
? [
|
||||
@@ -342,7 +413,38 @@ export function renderPaperclipWakePrompt(
|
||||
lines.push(`- omitted comments: ${normalized.missingCount}`);
|
||||
}
|
||||
|
||||
lines.push("", "New comments in order:");
|
||||
if (executionStage) {
|
||||
lines.push(
|
||||
`- execution wake role: ${executionStage.wakeRole ?? "unknown"}`,
|
||||
`- execution stage: ${executionStage.stageType ?? "unknown"}`,
|
||||
`- execution participant: ${principalLabel(executionStage.currentParticipant)}`,
|
||||
`- execution return assignee: ${principalLabel(executionStage.returnAssignee)}`,
|
||||
`- last decision outcome: ${executionStage.lastDecisionOutcome ?? "none"}`,
|
||||
);
|
||||
if (executionStage.allowedActions.length > 0) {
|
||||
lines.push(`- allowed actions: ${executionStage.allowedActions.join(", ")}`);
|
||||
}
|
||||
lines.push("");
|
||||
if (executionStage.wakeRole === "reviewer" || executionStage.wakeRole === "approver") {
|
||||
lines.push(
|
||||
`You are waking as the active ${executionStage.wakeRole} for this issue.`,
|
||||
"Do not execute the task itself or continue executor work.",
|
||||
"Review the issue and choose one of the allowed actions above.",
|
||||
"If you request changes, the workflow routes back to the stored return assignee.",
|
||||
"",
|
||||
);
|
||||
} else if (executionStage.wakeRole === "executor") {
|
||||
lines.push(
|
||||
"You are waking because changes were requested in the execution workflow.",
|
||||
"Address the requested changes on this issue and resubmit when the work is ready.",
|
||||
"",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.comments.length > 0) {
|
||||
lines.push("New comments in order:");
|
||||
}
|
||||
|
||||
for (const [index, comment] of normalized.comments.entries()) {
|
||||
const authorLabel = comment.authorId
|
||||
@@ -967,16 +1069,12 @@ export async function runChildProcess(
|
||||
}) as ChildProcessWithEvents;
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) {
|
||||
void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
|
||||
onLogError(err, runId, "failed to record child process metadata");
|
||||
});
|
||||
}
|
||||
const spawnPersistPromise =
|
||||
typeof child.pid === "number" && child.pid > 0 && opts.onSpawn
|
||||
? opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
|
||||
onLogError(err, runId, "failed to record child process metadata");
|
||||
})
|
||||
: Promise.resolve();
|
||||
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
|
||||
@@ -1014,6 +1112,15 @@ export async function runChildProcess(
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||
});
|
||||
|
||||
const stdin = child.stdin;
|
||||
if (opts.stdin != null && stdin) {
|
||||
void spawnPersistPromise.finally(() => {
|
||||
if (child.killed || stdin.destroyed) return;
|
||||
stdin.write(opts.stdin as string);
|
||||
stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
child.on("error", (err: Error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCodexStdoutLine } from "./parse-stdout.js";
|
||||
|
||||
describe("parseCodexStdoutLine", () => {
|
||||
it("marks completed tool_use items as resolved tool results", () => {
|
||||
const started = parseCodexStdoutLine(JSON.stringify({
|
||||
type: "item.started",
|
||||
item: {
|
||||
id: "tool-1",
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
input: { query: "paperclip" },
|
||||
},
|
||||
}), "2026-04-08T12:00:00.000Z");
|
||||
|
||||
const completed = parseCodexStdoutLine(JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "tool-1",
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
status: "completed",
|
||||
},
|
||||
}), "2026-04-08T12:00:01.000Z");
|
||||
|
||||
expect(started).toEqual([{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-08T12:00:00.000Z",
|
||||
name: "search",
|
||||
toolUseId: "tool-1",
|
||||
input: { query: "paperclip" },
|
||||
}]);
|
||||
expect(completed).toEqual([{
|
||||
kind: "tool_result",
|
||||
ts: "2026-04-08T12:00:01.000Z",
|
||||
toolUseId: "tool-1",
|
||||
content: "search completed",
|
||||
isError: false,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("keeps explicit tool_result payloads authoritative after tool_use completion", () => {
|
||||
const completed = parseCodexStdoutLine(JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "tool-2",
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-1",
|
||||
content: "final payload",
|
||||
status: "completed",
|
||||
},
|
||||
}), "2026-04-08T12:00:02.000Z");
|
||||
|
||||
expect(completed).toEqual([{
|
||||
kind: "tool_result",
|
||||
ts: "2026-04-08T12:00:02.000Z",
|
||||
toolUseId: "tool-1",
|
||||
content: "final payload",
|
||||
isError: false,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("marks failed completed tool_use items as error results", () => {
|
||||
const completed = parseCodexStdoutLine(JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "tool-3",
|
||||
type: "tool_use",
|
||||
name: "write_file",
|
||||
status: "error",
|
||||
error: { message: "permission denied" },
|
||||
},
|
||||
}), "2026-04-08T12:00:03.000Z");
|
||||
|
||||
expect(completed).toEqual([{
|
||||
kind: "tool_result",
|
||||
ts: "2026-04-08T12:00:03.000Z",
|
||||
toolUseId: "tool-3",
|
||||
content: "permission denied",
|
||||
isError: true,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,52 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
|
||||
return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }];
|
||||
}
|
||||
|
||||
function parseToolUseItem(
|
||||
item: Record<string, unknown>,
|
||||
ts: string,
|
||||
phase: "started" | "completed",
|
||||
): TranscriptEntry[] {
|
||||
const name = asString(item.name, "unknown");
|
||||
const toolUseId = asString(item.id, name || "tool_use");
|
||||
|
||||
if (phase === "started") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name,
|
||||
toolUseId,
|
||||
input: item.input ?? {},
|
||||
}];
|
||||
}
|
||||
|
||||
const status = asString(item.status);
|
||||
const isError =
|
||||
item.is_error === true ||
|
||||
status === "failed" ||
|
||||
status === "errored" ||
|
||||
status === "error" ||
|
||||
status === "cancelled";
|
||||
const rawContent =
|
||||
item.content ??
|
||||
item.output ??
|
||||
item.result ??
|
||||
item.error ??
|
||||
item.message;
|
||||
const content =
|
||||
asString(rawContent) ||
|
||||
errorText(rawContent) ||
|
||||
stringifyUnknown(rawContent) ||
|
||||
`${name} ${isError ? "failed" : "completed"}`;
|
||||
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content,
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
function parseCodexItem(
|
||||
item: Record<string, unknown>,
|
||||
ts: string,
|
||||
@@ -146,13 +192,7 @@ function parseCodexItem(
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: asString(item.name, "unknown"),
|
||||
toolUseId: asString(item.id),
|
||||
input: item.input ?? {},
|
||||
}];
|
||||
return parseToolUseItem(item, ts, phase);
|
||||
}
|
||||
|
||||
if (itemType === "tool_result" && phase === "completed") {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS "inbox_dismissals" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"item_key" text NOT NULL,
|
||||
"dismissed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_user_idx" ON "inbox_dismissals" USING btree ("company_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_item_idx" ON "inbox_dismissals" USING btree ("company_id","item_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "inbox_dismissals_company_user_item_idx" ON "inbox_dismissals" USING btree ("company_id","user_id","item_key");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -372,6 +372,13 @@
|
||||
"when": 1775571715162,
|
||||
"tag": "0052_mushy_trauma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 53,
|
||||
"version": "7",
|
||||
"when": 1775604018515,
|
||||
"tag": "0053_sharp_wild_child",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const inboxDismissals = pgTable(
|
||||
"inbox_dismissals",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
userId: text("user_id").notNull(),
|
||||
itemKey: text("item_key").notNull(),
|
||||
dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUserIdx: index("inbox_dismissals_company_user_idx").on(table.companyId, table.userId),
|
||||
companyItemIdx: index("inbox_dismissals_company_item_idx").on(table.companyId, table.itemKey),
|
||||
companyUserItemUnique: uniqueIndex("inbox_dismissals_company_user_item_idx").on(
|
||||
table.companyId,
|
||||
table.userId,
|
||||
table.itemKey,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -34,6 +34,7 @@ export { issueApprovals } from "./issue_approvals.js";
|
||||
export { issueComments } from "./issue_comments.js";
|
||||
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
|
||||
export { issueInboxArchives } from "./issue_inbox_archives.js";
|
||||
export { inboxDismissals } from "./inbox_dismissals.js";
|
||||
export { feedbackVotes } from "./feedback_votes.js";
|
||||
export { feedbackExports } from "./feedback_exports.js";
|
||||
export { issueReadStates } from "./issue_read_states.js";
|
||||
|
||||
@@ -288,6 +288,7 @@ export type {
|
||||
DashboardSummary,
|
||||
ActivityEvent,
|
||||
SidebarBadges,
|
||||
InboxDismissal,
|
||||
CompanyMembership,
|
||||
PrincipalPermissionGrant,
|
||||
Invite,
|
||||
|
||||
@@ -12,9 +12,18 @@ describe("routine variable helpers", () => {
|
||||
).toEqual(["repo", "priority"]);
|
||||
});
|
||||
|
||||
it("deduplicates placeholder names across the routine title and description", () => {
|
||||
expect(
|
||||
extractRoutineVariableNames([
|
||||
"Triage {{repo}}",
|
||||
"Review {{repo}} for {{priority}} bugs",
|
||||
]),
|
||||
).toEqual(["repo", "priority"]);
|
||||
});
|
||||
|
||||
it("preserves existing metadata when syncing variables from a template", () => {
|
||||
expect(
|
||||
syncRoutineVariablesWithTemplate("Review {{repo}} and {{priority}}", [
|
||||
syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{priority}}"], [
|
||||
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
|
||||
]),
|
||||
).toEqual([
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { RoutineVariable } from "./types/routine.js";
|
||||
|
||||
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
|
||||
type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>;
|
||||
|
||||
export function isValidRoutineVariableName(name: string): boolean {
|
||||
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
export function extractRoutineVariableNames(template: string | null | undefined): string[] {
|
||||
if (!template) return [];
|
||||
function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] {
|
||||
const templates = Array.isArray(input) ? input : [input];
|
||||
return templates.filter((template): template is string => typeof template === "string" && template.length > 0);
|
||||
}
|
||||
|
||||
export function extractRoutineVariableNames(template: RoutineTemplateInput): string[] {
|
||||
const found = new Set<string>();
|
||||
for (const match of template.matchAll(ROUTINE_VARIABLE_MATCHER)) {
|
||||
const name = match[1];
|
||||
if (name && !found.has(name)) {
|
||||
found.add(name);
|
||||
for (const source of normalizeRoutineTemplateInput(template)) {
|
||||
for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) {
|
||||
const name = match[1];
|
||||
if (name && !found.has(name)) {
|
||||
found.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
@@ -30,7 +37,7 @@ function defaultRoutineVariable(name: string): RoutineVariable {
|
||||
}
|
||||
|
||||
export function syncRoutineVariablesWithTemplate(
|
||||
template: string | null | undefined,
|
||||
template: RoutineTemplateInput,
|
||||
existing: RoutineVariable[] | null | undefined,
|
||||
): RoutineVariable[] {
|
||||
const names = extractRoutineVariableNames(template);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface InboxDismissal {
|
||||
id: string;
|
||||
companyId: string;
|
||||
userId: string;
|
||||
itemKey: string;
|
||||
dismissedAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -164,6 +164,7 @@ export type { LiveEvent } from "./live.js";
|
||||
export type { DashboardSummary } from "./dashboard.js";
|
||||
export type { ActivityEvent } from "./activity.js";
|
||||
export type { SidebarBadges } from "./sidebar-badges.js";
|
||||
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||
export type {
|
||||
CompanyMembership,
|
||||
PrincipalPermissionGrant,
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { repoRoot } from "./dev-service-profile.ts";
|
||||
|
||||
type WorkspaceLinkMismatch = {
|
||||
workspaceDir: string;
|
||||
packageName: string;
|
||||
expectedPath: string;
|
||||
actualPath: string | null;
|
||||
};
|
||||
|
||||
function readJsonFile(filePath: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
||||
const packagePaths = new Map<string, string>();
|
||||
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
||||
|
||||
function visit(dirPath: string) {
|
||||
const packageJsonPath = path.join(dirPath, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
|
||||
packagePaths.set(packageJson.name, dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (ignoredDirNames.has(entry.name)) continue;
|
||||
visit(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
visit(path.join(rootDir, "packages"));
|
||||
visit(path.join(rootDir, "server"));
|
||||
visit(path.join(rootDir, "ui"));
|
||||
visit(path.join(rootDir, "cli"));
|
||||
|
||||
return packagePaths;
|
||||
}
|
||||
|
||||
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
|
||||
|
||||
function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] {
|
||||
const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json"));
|
||||
const dependencies = {
|
||||
...(packageJson.dependencies as Record<string, unknown> | undefined),
|
||||
...(packageJson.devDependencies as Record<string, unknown> | undefined),
|
||||
};
|
||||
const mismatches: WorkspaceLinkMismatch[] = [];
|
||||
|
||||
for (const [packageName, version] of Object.entries(dependencies)) {
|
||||
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
|
||||
|
||||
const expectedPath = workspacePackagePaths.get(packageName);
|
||||
if (!expectedPath) continue;
|
||||
|
||||
const linkPath = path.join(repoRoot, workspaceDir, "node_modules", ...packageName.split("/"));
|
||||
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
|
||||
if (actualPath === path.resolve(expectedPath)) continue;
|
||||
|
||||
mismatches.push({
|
||||
workspaceDir,
|
||||
packageName,
|
||||
expectedPath: path.resolve(expectedPath),
|
||||
actualPath,
|
||||
});
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
async function ensureWorkspaceLinksCurrent(workspaceDir: string) {
|
||||
const mismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||
if (mismatches.length === 0) return;
|
||||
|
||||
console.log(`[paperclip] detected stale workspace package links for ${workspaceDir}; relinking dependencies...`);
|
||||
for (const mismatch of mismatches) {
|
||||
console.log(
|
||||
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const mismatch of mismatches) {
|
||||
const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/"));
|
||||
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
await fs.symlink(mismatch.expectedPath, linkPath);
|
||||
}
|
||||
|
||||
const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||
if (remainingMismatches.length === 0) return;
|
||||
|
||||
throw new Error(
|
||||
`Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const workspaceDir of ["server", "ui"]) {
|
||||
await ensureWorkspaceLinksCurrent(workspaceDir);
|
||||
}
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Kill all "Google Chrome for Testing" processes (agent headless browsers).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/kill-agent-browsers.sh # kill all
|
||||
# scripts/kill-agent-browsers.sh --dry # preview what would be killed
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
pids=()
|
||||
lines=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
pids+=("$pid")
|
||||
lines+=("$line")
|
||||
done < <(ps aux | grep 'Google Chrome for Testing' | grep -v grep || true)
|
||||
|
||||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||
echo "No Google Chrome for Testing processes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found ${#pids[@]} Google Chrome for Testing process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!pids[@]}"; do
|
||||
line="${lines[$i]}"
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
start=$(echo "$line" | awk '{print $9}')
|
||||
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Dry run — re-run without --dry to kill these processes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Sending SIGTERM..."
|
||||
for pid in "${pids[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||
done
|
||||
|
||||
sleep 2
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Done."
|
||||
+43
-1
@@ -24,6 +24,8 @@ node_lines=()
|
||||
pg_pids=()
|
||||
pg_pidfiles=()
|
||||
pg_data_dirs=()
|
||||
browser_pids=()
|
||||
browser_lines=()
|
||||
|
||||
is_pid_running() {
|
||||
local pid="$1"
|
||||
@@ -87,6 +89,14 @@ while IFS= read -r line; do
|
||||
node_lines+=("$line")
|
||||
done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true)
|
||||
|
||||
# --- Agent browser processes (headless Chrome from ~/.agent-browser) ---
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
browser_pids+=("$pid")
|
||||
browser_lines+=("$line")
|
||||
done < <(ps aux | grep -E 'agent-browser/browsers/chrome-.*/Google Chrome for Testing' | grep -v grep || true)
|
||||
|
||||
candidate_pidfiles=()
|
||||
candidate_pidfiles+=(
|
||||
"$HOME"/.paperclip/instances/*/db/postmaster.pid
|
||||
@@ -107,7 +117,7 @@ for pidfile in "${candidate_pidfiles[@]:-}"; do
|
||||
append_postgres_from_pidfile "$pidfile"
|
||||
done
|
||||
|
||||
if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 ]]; then
|
||||
if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 && ${#browser_pids[@]} -eq 0 ]]; then
|
||||
echo "No Paperclip dev processes found."
|
||||
exit 0
|
||||
fi
|
||||
@@ -144,6 +154,22 @@ if [[ ${#pg_pids[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#browser_pids[@]} -gt 0 ]]; then
|
||||
echo "Found ${#browser_pids[@]} agent browser process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!browser_pids[@]:-}"; do
|
||||
line="${browser_lines[$i]}"
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
start=$(echo "$line" | awk '{print $9}')
|
||||
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||
done
|
||||
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Dry run — re-run without --dry to kill these processes."
|
||||
exit 0
|
||||
@@ -158,6 +184,13 @@ if [[ ${#node_pids[@]} -gt 0 ]]; then
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
if [[ ${#browser_pids[@]} -gt 0 ]]; then
|
||||
echo "Sending SIGTERM to agent browser processes..."
|
||||
for pid in "${browser_pids[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||
done
|
||||
fi
|
||||
|
||||
leftover_pg_pids=()
|
||||
leftover_pg_data_dirs=()
|
||||
for i in "${!pg_pids[@]:-}"; do
|
||||
@@ -203,4 +236,13 @@ if [[ ${#pg_pids[@]} -gt 0 ]]; then
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#browser_pids[@]} -gt 0 ]]; then
|
||||
for pid in "${browser_pids[@]:-}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo " agent browser $pid still alive, sending SIGKILL..."
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
|
||||
+4
-5
@@ -32,16 +32,15 @@
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"preflight:workspace-links": "tsx ../scripts/ensure-workspace-package-links.ts",
|
||||
"dev": "pnpm run preflight:workspace-links && tsx src/index.ts",
|
||||
"dev:watch": "pnpm run preflight:workspace-links && cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
|
||||
"dev": "tsx src/index.ts",
|
||||
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
|
||||
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
||||
"build": "pnpm run preflight:workspace-links && tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
|
||||
"build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
|
||||
"prepack": "pnpm run prepare:ui-dist",
|
||||
"postpack": "rm -rf ui-dist",
|
||||
"clean": "rm -rf dist",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
|
||||
@@ -230,6 +230,80 @@ describe("agent permission routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
intervalSec: 3600,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
intervalSec: 3600,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes hire requests to disable timer heartbeats by default", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agent-hires`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
intervalSec: 3600,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
intervalSec: 3600,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes explicit task assignment access on agent detail", async () => {
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -369,6 +369,252 @@ describe("codex execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders execution-stage wake instructions for reviewer and executor roles", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-stage-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-stage-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "execution_review_requested",
|
||||
paperclipWake: {
|
||||
reason: "execution_review_requested",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1207",
|
||||
title: "implement the plan of PAP-1200",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
},
|
||||
executionStage: {
|
||||
wakeRole: "reviewer",
|
||||
stageId: "stage-1",
|
||||
stageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "qa-agent" },
|
||||
returnAssignee: { type: "agent", agentId: "coder-agent" },
|
||||
lastDecisionOutcome: null,
|
||||
allowedActions: ["approve", "request_changes"],
|
||||
},
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.prompt).toContain("execution wake role: reviewer");
|
||||
expect(capture.prompt).toContain("You are waking as the active reviewer for this issue.");
|
||||
expect(capture.prompt).toContain("Do not execute the task itself or continue executor work.");
|
||||
expect(capture.prompt).toContain("allowed actions: approve, request_changes");
|
||||
|
||||
const executorCapturePath = path.join(root, "capture-executor.json");
|
||||
const executorResult = await execute({
|
||||
runId: "run-stage-wake-executor",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: executorCapturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "execution_changes_requested",
|
||||
paperclipWake: {
|
||||
reason: "execution_changes_requested",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1207",
|
||||
title: "implement the plan of PAP-1200",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
executionStage: {
|
||||
wakeRole: "executor",
|
||||
stageId: "stage-1",
|
||||
stageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "qa-agent" },
|
||||
returnAssignee: { type: "agent", agentId: "coder-agent" },
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
allowedActions: ["address_changes", "resubmit"],
|
||||
},
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(executorResult.exitCode).toBe(0);
|
||||
const executorCapture = JSON.parse(await fs.readFile(executorCapturePath, "utf8")) as CapturePayload;
|
||||
expect(executorCapture.prompt).toContain("execution wake role: executor");
|
||||
expect(executorCapture.prompt).toContain("You are waking because changes were requested in the execution workflow.");
|
||||
expect(executorCapture.prompt).toContain("allowed actions: address_changes, resubmit");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renders an issue-scoped wake prompt even when the wake has no comments yet", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-issue-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-issue-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_assigned",
|
||||
paperclipWake: {
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1201",
|
||||
title: "Fix gallery opening for inline images",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(capture.paperclipWakePayloadJson).not.toBeNull();
|
||||
expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
identifier: "PAP-1201",
|
||||
title: "Fix gallery opening for inline images",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: [],
|
||||
});
|
||||
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images");
|
||||
expect(capture.prompt).toContain("- pending comments: 0/0");
|
||||
expect(capture.prompt).toContain("- issue status: todo");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
||||
@@ -488,6 +488,23 @@ describe("heartbeat comment wake batching", () => {
|
||||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
const firstPayload = gateway.getAgentPayloads()[0] ?? {};
|
||||
expect(firstPayload.paperclip).toMatchObject({
|
||||
wake: {
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
id: issueId,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
title: "Require a comment",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: [],
|
||||
},
|
||||
});
|
||||
expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload");
|
||||
expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`);
|
||||
gateway.releaseFirstWait();
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
|
||||
@@ -272,6 +272,18 @@ describe("shouldResetTaskSessionForWake", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
||||
});
|
||||
|
||||
it("resets session context on execution review wakes", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_review_requested" })).toBe(true);
|
||||
});
|
||||
|
||||
it("resets session context on execution approval wakes", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_approval_requested" })).toBe(true);
|
||||
});
|
||||
|
||||
it("resets session context on execution changes-requested wakes", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_changes_requested" })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves session context on timer heartbeats", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
inboxDismissals,
|
||||
invites,
|
||||
joinRequests,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres inbox dismissal tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("inbox dismissals", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let dismissalsSvc!: ReturnType<typeof inboxDismissalService>;
|
||||
let badgesSvc!: ReturnType<typeof sidebarBadgeService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-dismissals-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
dismissalsSvc = inboxDismissalService(db);
|
||||
badgesSvc = sidebarBadgeService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(inboxDismissals);
|
||||
await db.delete(joinRequests);
|
||||
await db.delete(invites);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(approvals);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("upserts a single dismissal record per user and inbox item key", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
const firstDismissedAt = new Date("2026-03-11T01:00:00.000Z");
|
||||
const secondDismissedAt = new Date("2026-03-11T02:00:00.000Z");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", firstDismissedAt);
|
||||
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", secondDismissedAt);
|
||||
|
||||
const dismissals = await dismissalsSvc.list(companyId, userId);
|
||||
|
||||
expect(dismissals).toHaveLength(1);
|
||||
expect(dismissals[0]?.itemKey).toBe("approval:approval-1");
|
||||
expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString());
|
||||
});
|
||||
|
||||
it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
const primaryAgentId = randomUUID();
|
||||
const secondaryAgentId = randomUUID();
|
||||
const hiddenApprovalId = randomUUID();
|
||||
const resurfacedApprovalId = randomUUID();
|
||||
const inviteId = randomUUID();
|
||||
const hiddenJoinRequestId = randomUUID();
|
||||
const hiddenRunId = randomUUID();
|
||||
const visibleRunId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: primaryAgentId,
|
||||
companyId,
|
||||
name: "Primary",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: secondaryAgentId,
|
||||
companyId,
|
||||
name: "Secondary",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(approvals).values([
|
||||
{
|
||||
id: hiddenApprovalId,
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: {},
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: resurfacedApprovalId,
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "revision_requested",
|
||||
payload: {},
|
||||
updatedAt: new Date("2026-03-11T03:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(invites).values({
|
||||
id: inviteId,
|
||||
companyId,
|
||||
inviteType: "company_join",
|
||||
tokenHash: "hash-1",
|
||||
allowedJoinTypes: "both",
|
||||
expiresAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
await db.insert(joinRequests).values({
|
||||
id: hiddenJoinRequestId,
|
||||
inviteId,
|
||||
companyId,
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestIp: "127.0.0.1",
|
||||
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
id: hiddenRunId,
|
||||
companyId,
|
||||
agentId: primaryAgentId,
|
||||
invocationSource: "assignment",
|
||||
status: "failed",
|
||||
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: visibleRunId,
|
||||
companyId,
|
||||
agentId: secondaryAgentId,
|
||||
invocationSource: "assignment",
|
||||
status: "timed_out",
|
||||
createdAt: new Date("2026-03-11T04:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T04:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
await dismissalsSvc.dismiss(companyId, userId, `approval:${hiddenApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||
await dismissalsSvc.dismiss(companyId, userId, `approval:${resurfacedApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||
await dismissalsSvc.dismiss(companyId, userId, `join:${hiddenJoinRequestId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||
await dismissalsSvc.dismiss(companyId, userId, `run:${hiddenRunId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||
|
||||
const dismissedAtByKey = new Map(
|
||||
(await dismissalsSvc.list(companyId, userId)).map((dismissal) => [
|
||||
dismissal.itemKey,
|
||||
new Date(dismissal.dismissedAt).getTime(),
|
||||
]),
|
||||
);
|
||||
|
||||
const badges = await badgesSvc.get(companyId, {
|
||||
dismissals: dismissedAtByKey,
|
||||
joinRequests: [{
|
||||
id: hiddenJoinRequestId,
|
||||
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
}],
|
||||
unreadTouchedIssues: 1,
|
||||
});
|
||||
|
||||
expect(badges).toEqual({
|
||||
inbox: 3,
|
||||
approvals: 1,
|
||||
failedRuns: 1,
|
||||
joinRequests: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}),
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeIssue() {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-580",
|
||||
title: "Activity event issue",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue activity event routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("logs blocker activity with added and removed issue summaries", async () => {
|
||||
const issue = makeIssue();
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getRelationSummaries
|
||||
.mockResolvedValueOnce({
|
||||
blockedBy: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
identifier: "PAP-10",
|
||||
title: "Old blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
blockedBy: [
|
||||
{
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
identifier: "PAP-11",
|
||||
title: "New blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
});
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.blockers_updated",
|
||||
details: expect.objectContaining({
|
||||
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
|
||||
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
|
||||
addedBlockedByIssues: [
|
||||
{
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
identifier: "PAP-11",
|
||||
title: "New blocker",
|
||||
},
|
||||
],
|
||||
removedBlockedByIssues: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
identifier: "PAP-10",
|
||||
title: "Old blocker",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
|
||||
const existingPolicy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555" }],
|
||||
},
|
||||
{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
type: "approval",
|
||||
participants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const nextPolicy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" }],
|
||||
},
|
||||
{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
type: "approval",
|
||||
participants: [{ type: "user", userId: "local-board" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue(),
|
||||
executionPolicy: existingPolicy,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
executionPolicy: patch.executionPolicy,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ executionPolicy: nextPolicy });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.reviewers_updated",
|
||||
details: expect.objectContaining({
|
||||
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.approvers_updated",
|
||||
details: expect.objectContaining({
|
||||
participants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -93,7 +93,7 @@ async function installActor(app: express.Express, actor?: Record<string, unknown
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
(req as any).actor = actor ?? {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
@@ -176,6 +176,10 @@ describe("issue comment reopen routes", () => {
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("treats reopen=true as a no-op when the issue is already open", async () => {
|
||||
@@ -343,4 +347,146 @@ describe("issue comment reopen routes", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("coerces executor handoff patches into workflow-controlled review wakes", async () => {
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}),
|
||||
)
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
executionState: expect.objectContaining({
|
||||
status: "pending",
|
||||
currentStageType: "review",
|
||||
currentParticipant: expect.objectContaining({
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
}),
|
||||
returnAssignee: expect.objectContaining({
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
expect.objectContaining({
|
||||
reason: "execution_review_requested",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
executionStage: expect.objectContaining({
|
||||
wakeRole: "reviewer",
|
||||
stageType: "review",
|
||||
allowedActions: ["approve", "request_changes"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wakes the return assignee with execution_changes_requested", async () => {
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
runId: "run-2",
|
||||
}),
|
||||
)
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({
|
||||
status: "in_progress",
|
||||
comment: "Needs another pass",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
reason: "execution_changes_requested",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
executionStage: expect.objectContaining({
|
||||
wakeRole: "executor",
|
||||
stageType: "review",
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
allowedActions: ["address_changes", "resubmit"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp(
|
||||
actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("issue execution policy routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-999",
|
||||
title: "Execution policy edit",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ executionPolicy: policy });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
expect.objectContaining({
|
||||
executionPolicy: policy,
|
||||
actorAgentId: null,
|
||||
actorUserId: "local-board",
|
||||
}),
|
||||
);
|
||||
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(updatePatch.status).toBeUndefined();
|
||||
expect(updatePatch.assigneeAgentId).toBeUndefined();
|
||||
expect(updatePatch.assigneeUserId).toBeUndefined();
|
||||
expect(updatePatch.executionState).toBeUndefined();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent stage advances from non-participants", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const approverAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const executorAgentId = "22222222-2222-4222-8222-222222222222";
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
{
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
type: "approval",
|
||||
participants: [{ type: "agent", agentId: approverAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1000",
|
||||
title: "Execution policy guard",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: "11111111-1111-4111-8111-111111111111",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: executorAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
|
||||
const res = await request(
|
||||
createApp({
|
||||
type: "agent",
|
||||
agentId: approverAgentId,
|
||||
companyId: "company-1",
|
||||
source: "api_key",
|
||||
runId: "run-1",
|
||||
}),
|
||||
)
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "done", comment: "Skipping review." });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("active review participant");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -413,33 +413,45 @@ describe("issue execution policy transitions", () => {
|
||||
const policy = twoStagePolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
|
||||
it("non-participant cannot advance stage via status change", () => {
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
it("non-participant stage updates are coerced back to the active stage", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Trying to bypass review",
|
||||
}),
|
||||
).toThrow("Only the active reviewer or approver can advance");
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: { assigneeUserId: boardUserId },
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Trying to bypass review",
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
},
|
||||
});
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
it("non-participant can still post non-advancing updates", () => {
|
||||
@@ -663,6 +675,7 @@ describe("issue execution policy transitions", () => {
|
||||
|
||||
describe("no-op transitions", () => {
|
||||
const policy = twoStagePolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
|
||||
it("non-done status change without review context is a no-op", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
@@ -682,6 +695,72 @@ describe("issue execution policy transitions", () => {
|
||||
expect(result.patch).toEqual({});
|
||||
});
|
||||
|
||||
it("coerces a malformed executor in_review patch into the first policy stage", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "in_review",
|
||||
requestedAssigneePatch: { assigneeUserId: boardUserId },
|
||||
actor: { agentId: coderAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reasserts the active stage when issue status drifted out of in_review", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "in_progress",
|
||||
requestedAssigneePatch: { assigneeAgentId: coderAgentId },
|
||||
actor: { agentId: coderAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("no policy and no state is a no-op", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
@@ -699,6 +778,25 @@ describe("issue execution policy transitions", () => {
|
||||
|
||||
expect(result.patch).toEqual({});
|
||||
});
|
||||
|
||||
it("does not auto-start workflow when policy is added to an already in_review issue", () => {
|
||||
const reviewOnly = reviewOnlyPolicy();
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: boardUserId,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
},
|
||||
policy: reviewOnly,
|
||||
requestedStatus: undefined,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
});
|
||||
|
||||
expect(result.patch).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-participant stages", () => {
|
||||
@@ -895,4 +993,100 @@ describe("issue execution policy transitions", () => {
|
||||
expect(result.patch.assigneeUserId).toBe(boardUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy edits while a stage is active", () => {
|
||||
it("clears the active execution state when its stage is removed from the policy", () => {
|
||||
const reviewAndApproval = twoStagePolicy();
|
||||
const approvalOnly = approvalOnlyPolicy();
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: reviewAndApproval,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewAndApproval.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy: approvalOnly,
|
||||
requestedStatus: undefined,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reassigns the active stage when the current participant is removed", () => {
|
||||
const policy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [
|
||||
{ type: "agent", agentId: qaAgentId },
|
||||
{ type: "agent", agentId: ctoAgentId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const updatedPolicy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: ctoAgentId }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
...updatedPolicy,
|
||||
stages: [{ ...updatedPolicy.stages[0], id: policy.stages[0].id }],
|
||||
},
|
||||
requestedStatus: undefined,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: ctoAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: ctoAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,7 +332,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "repo triage",
|
||||
title: "repo triage for {{repo}}",
|
||||
description: "Review {{repo}} for {{priority}} bugs",
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
@@ -346,6 +346,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(variableRoutine.variables.map((variable) => variable.name)).toEqual(["repo", "priority"]);
|
||||
|
||||
const run = await svc.runRoutine(variableRoutine.id, {
|
||||
source: "manual",
|
||||
@@ -353,7 +354,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
});
|
||||
|
||||
const storedIssue = await db
|
||||
.select({ description: issues.description })
|
||||
.select({ title: issues.title, description: issues.description })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, run.linkedIssueId!))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
@@ -363,6 +364,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
.where(eq(routineRuns.id, run.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(storedIssue?.title).toBe("repo triage for paperclip");
|
||||
expect(storedIssue?.description).toBe("Review paperclip for high bugs");
|
||||
expect(storedRun?.triggerPayload).toEqual({
|
||||
variables: {
|
||||
|
||||
@@ -200,6 +200,7 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
||||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||
await fs.mkdir(stalePackageDir, { recursive: true });
|
||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "server", "package.json"),
|
||||
@@ -235,6 +236,7 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
||||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links-current\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "server", "package.json"),
|
||||
@@ -255,6 +257,45 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
||||
|
||||
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
|
||||
});
|
||||
|
||||
it("skips relinking outside linked git worktrees", async () => {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-"));
|
||||
const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-stale-"));
|
||||
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
|
||||
const expectedPackageDir = path.join(repoRoot, "packages", "db");
|
||||
const stalePackageDir = path.join(staleRoot, "db");
|
||||
|
||||
await fs.mkdir(path.join(repoRoot, ".git"), { recursive: true });
|
||||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||
await fs.mkdir(stalePackageDir, { recursive: true });
|
||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "server", "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@paperclipai/server",
|
||||
dependencies: {
|
||||
"@paperclipai/db": "workspace:*",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(expectedPackageDir, "package.json"),
|
||||
JSON.stringify({ name: "@paperclipai/db" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stalePackageDir, "package.json"),
|
||||
JSON.stringify({ name: "@paperclipai/db" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db"));
|
||||
|
||||
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
|
||||
expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(stalePackageDir));
|
||||
});
|
||||
});
|
||||
|
||||
describe("realizeExecutionWorkspace", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
|
||||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
@@ -166,6 +167,7 @@ export async function createApp(
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(inboxDismissalRoutes(db));
|
||||
api.use(instanceSettingsRoutes(db));
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = createPluginWorkerManager();
|
||||
|
||||
@@ -449,11 +449,25 @@ export function agentRoutes(db: Db) {
|
||||
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
|
||||
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
|
||||
return {
|
||||
enabled: parseBooleanLike(heartbeat.enabled) ?? true,
|
||||
enabled: parseBooleanLike(heartbeat.enabled) ?? false,
|
||||
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNewAgentRuntimeConfig(runtimeConfig: unknown): Record<string, unknown> {
|
||||
const parsedRuntimeConfig = asRecord(runtimeConfig);
|
||||
const normalizedRuntimeConfig = parsedRuntimeConfig ? { ...parsedRuntimeConfig } : {};
|
||||
const parsedHeartbeat = asRecord(normalizedRuntimeConfig.heartbeat);
|
||||
const heartbeat = parsedHeartbeat ? { ...parsedHeartbeat } : {};
|
||||
|
||||
if (parseBooleanLike(heartbeat.enabled) == null) {
|
||||
heartbeat.enabled = false;
|
||||
}
|
||||
|
||||
normalizedRuntimeConfig.heartbeat = heartbeat;
|
||||
return normalizedRuntimeConfig;
|
||||
}
|
||||
|
||||
function generateEd25519PrivateKeyPem(): string {
|
||||
const { privateKey } = generateKeyPairSync("ed25519");
|
||||
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||
@@ -1308,6 +1322,7 @@ export function agentRoutes(db: Db) {
|
||||
const normalizedHireInput = {
|
||||
...hireInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
runtimeConfig: normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig),
|
||||
};
|
||||
|
||||
const company = await db
|
||||
@@ -1474,6 +1489,7 @@ export function agentRoutes(db: Db) {
|
||||
const createdAgent = await svc.create(companyId, {
|
||||
...createInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
runtimeConfig: normalizeNewAgentRuntimeConfig(createInput.runtimeConfig),
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { inboxDismissalService, logActivity } from "../services/index.js";
|
||||
|
||||
const inboxDismissalSchema = z.object({
|
||||
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
|
||||
});
|
||||
|
||||
export function inboxDismissalRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = inboxDismissalService(db);
|
||||
|
||||
router.get("/companies/:companyId/inbox-dismissals", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return;
|
||||
}
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return;
|
||||
}
|
||||
const dismissals = await svc.list(companyId, req.actor.userId);
|
||||
res.json(dismissals);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/inbox-dismissals",
|
||||
validate(inboxDismissalSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return;
|
||||
}
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date());
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "inbox.dismissed",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: {
|
||||
userId: req.actor.userId,
|
||||
itemKey: dismissal.itemKey,
|
||||
dismissedAt: dismissal.dismissedAt,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(dismissal);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
|
||||
export { activityRoutes } from "./activity.js";
|
||||
export { dashboardRoutes } from "./dashboard.js";
|
||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
|
||||
export { llmRoutes } from "./llms.js";
|
||||
export { accessRoutes } from "./access.js";
|
||||
export { instanceSettingsRoutes } from "./instance-settings.js";
|
||||
|
||||
+326
-27
@@ -56,13 +56,219 @@ import {
|
||||
SVG_CONTENT_TYPE,
|
||||
} from "../attachment-types.js";
|
||||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
|
||||
import {
|
||||
applyIssueExecutionPolicyTransition,
|
||||
normalizeIssueExecutionPolicy,
|
||||
parseIssueExecutionState,
|
||||
} from "../services/issue-execution-policy.js";
|
||||
|
||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||
const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||
interrupt: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
|
||||
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
|
||||
type ActivityIssueRelationSummary = {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
};
|
||||
type ActivityExecutionParticipant = Pick<
|
||||
NormalizedExecutionPolicy["stages"][number]["participants"][number],
|
||||
"type" | "agentId" | "userId"
|
||||
>;
|
||||
type ExecutionStageWakeContext = {
|
||||
wakeRole: "reviewer" | "approver" | "executor";
|
||||
stageId: string | null;
|
||||
stageType: ParsedExecutionState["currentStageType"];
|
||||
currentParticipant: ParsedExecutionState["currentParticipant"];
|
||||
returnAssignee: ParsedExecutionState["returnAssignee"];
|
||||
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
|
||||
allowedActions: string[];
|
||||
};
|
||||
|
||||
function executionPrincipalsEqual(
|
||||
left: ParsedExecutionState["currentParticipant"] | null,
|
||||
right: ParsedExecutionState["currentParticipant"] | null,
|
||||
) {
|
||||
if (!left || !right || left.type !== right.type) return false;
|
||||
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId;
|
||||
}
|
||||
|
||||
function executionParticipantMatchesAgent(
|
||||
participant: ParsedExecutionState["currentParticipant"] | null,
|
||||
agentId: string | null | undefined,
|
||||
) {
|
||||
return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId;
|
||||
}
|
||||
|
||||
function buildExecutionStageWakeContext(input: {
|
||||
state: ParsedExecutionState;
|
||||
wakeRole: ExecutionStageWakeContext["wakeRole"];
|
||||
allowedActions: string[];
|
||||
}): ExecutionStageWakeContext {
|
||||
return {
|
||||
wakeRole: input.wakeRole,
|
||||
stageId: input.state.currentStageId,
|
||||
stageType: input.state.currentStageType,
|
||||
currentParticipant: input.state.currentParticipant,
|
||||
returnAssignee: input.state.returnAssignee,
|
||||
lastDecisionOutcome: input.state.lastDecisionOutcome,
|
||||
allowedActions: input.allowedActions,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeIssueRelationForActivity(relation: {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
}): ActivityIssueRelationSummary {
|
||||
return {
|
||||
id: relation.id,
|
||||
identifier: relation.identifier,
|
||||
title: relation.title,
|
||||
};
|
||||
}
|
||||
|
||||
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
|
||||
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
|
||||
}
|
||||
|
||||
function summarizeExecutionParticipants(
|
||||
policy: NormalizedExecutionPolicy | null,
|
||||
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
|
||||
): ActivityExecutionParticipant[] {
|
||||
const stage = policy?.stages.find((candidate) => candidate.type === stageType);
|
||||
return (
|
||||
stage?.participants.map((participant) => ({
|
||||
type: participant.type,
|
||||
agentId: participant.agentId ?? null,
|
||||
userId: participant.userId ?? null,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function diffExecutionParticipants(
|
||||
previousPolicy: NormalizedExecutionPolicy | null,
|
||||
nextPolicy: NormalizedExecutionPolicy | null,
|
||||
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
|
||||
) {
|
||||
const previousParticipants = summarizeExecutionParticipants(previousPolicy, stageType);
|
||||
const nextParticipants = summarizeExecutionParticipants(nextPolicy, stageType);
|
||||
const previousByKey = new Map(previousParticipants.map((participant) => [
|
||||
activityExecutionParticipantKey(participant),
|
||||
participant,
|
||||
]));
|
||||
const nextByKey = new Map(nextParticipants.map((participant) => [
|
||||
activityExecutionParticipantKey(participant),
|
||||
participant,
|
||||
]));
|
||||
|
||||
return {
|
||||
participants: nextParticipants,
|
||||
addedParticipants: nextParticipants.filter((participant) => !previousByKey.has(activityExecutionParticipantKey(participant))),
|
||||
removedParticipants: previousParticipants.filter((participant) => !nextByKey.has(activityExecutionParticipantKey(participant))),
|
||||
};
|
||||
}
|
||||
|
||||
function buildExecutionStageWakeup(input: {
|
||||
issueId: string;
|
||||
previousState: ParsedExecutionState | null;
|
||||
nextState: ParsedExecutionState | null;
|
||||
interruptedRunId: string | null;
|
||||
requestedByActorType: "user" | "agent";
|
||||
requestedByActorId: string;
|
||||
}) {
|
||||
const { issueId, previousState, nextState, interruptedRunId } = input;
|
||||
if (!nextState) return null;
|
||||
|
||||
if (nextState.status === "pending") {
|
||||
const agentId =
|
||||
nextState.currentParticipant?.type === "agent" ? (nextState.currentParticipant.agentId ?? null) : null;
|
||||
const stageChanged =
|
||||
previousState?.status !== "pending" ||
|
||||
previousState?.currentStageId !== nextState.currentStageId ||
|
||||
!executionPrincipalsEqual(previousState?.currentParticipant ?? null, nextState.currentParticipant ?? null);
|
||||
if (!agentId || !stageChanged) return null;
|
||||
|
||||
const reason =
|
||||
nextState.currentStageType === "approval" ? "execution_approval_requested" : "execution_review_requested";
|
||||
const executionStage = buildExecutionStageWakeContext({
|
||||
state: nextState,
|
||||
wakeRole: nextState.currentStageType === "approval" ? "approver" : "reviewer",
|
||||
allowedActions: ["approve", "request_changes"],
|
||||
});
|
||||
|
||||
return {
|
||||
agentId,
|
||||
wakeup: {
|
||||
source: "assignment" as const,
|
||||
triggerDetail: "system" as const,
|
||||
reason,
|
||||
payload: {
|
||||
issueId,
|
||||
mutation: "update",
|
||||
executionStage,
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
requestedByActorType: input.requestedByActorType,
|
||||
requestedByActorId: input.requestedByActorId,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: reason,
|
||||
source: "issue.execution_stage",
|
||||
executionStage,
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (nextState.status === "changes_requested") {
|
||||
const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null;
|
||||
const becameChangesRequested =
|
||||
previousState?.status !== "changes_requested" ||
|
||||
previousState?.lastDecisionId !== nextState.lastDecisionId ||
|
||||
!executionPrincipalsEqual(previousState?.returnAssignee ?? null, nextState.returnAssignee ?? null);
|
||||
if (!agentId || !becameChangesRequested) return null;
|
||||
|
||||
const executionStage = buildExecutionStageWakeContext({
|
||||
state: nextState,
|
||||
wakeRole: "executor",
|
||||
allowedActions: ["address_changes", "resubmit"],
|
||||
});
|
||||
|
||||
return {
|
||||
agentId,
|
||||
wakeup: {
|
||||
source: "assignment" as const,
|
||||
triggerDetail: "system" as const,
|
||||
reason: "execution_changes_requested",
|
||||
payload: {
|
||||
issueId,
|
||||
mutation: "update",
|
||||
executionStage,
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
requestedByActorType: input.requestedByActorType,
|
||||
requestedByActorId: input.requestedByActorId,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "execution_changes_requested",
|
||||
source: "issue.execution_stage",
|
||||
executionStage,
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function issueRoutes(
|
||||
db: Db,
|
||||
storage: StorageService,
|
||||
@@ -1066,9 +1272,10 @@ export function issueRoutes(
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
const issue = await svc.create(companyId, {
|
||||
...req.body,
|
||||
executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
||||
executionPolicy,
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
@@ -1110,24 +1317,6 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const assigneeWillChange =
|
||||
(req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
|
||||
(req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
|
||||
|
||||
const isAgentReturningIssueToCreator =
|
||||
req.actor.type === "agent" &&
|
||||
!!req.actor.agentId &&
|
||||
existing.assigneeAgentId === req.actor.agentId &&
|
||||
req.body.assigneeAgentId === null &&
|
||||
typeof req.body.assigneeUserId === "string" &&
|
||||
!!existing.createdByUserId &&
|
||||
req.body.assigneeUserId === existing.createdByUserId;
|
||||
|
||||
if (assigneeWillChange) {
|
||||
if (!isAgentReturningIssueToCreator) {
|
||||
await assertCanAssignTasks(req, existing.companyId);
|
||||
}
|
||||
}
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1191,14 +1380,20 @@ export function issueRoutes(
|
||||
if (req.body.executionPolicy !== undefined) {
|
||||
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
}
|
||||
const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null);
|
||||
const nextExecutionPolicy =
|
||||
updateFields.executionPolicy !== undefined
|
||||
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
|
||||
: previousExecutionPolicy;
|
||||
|
||||
const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined;
|
||||
const requestedAssigneePatchProvided =
|
||||
req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined;
|
||||
|
||||
const transition = applyIssueExecutionPolicyTransition({
|
||||
issue: existing,
|
||||
policy:
|
||||
updateFields.executionPolicy !== undefined
|
||||
? (updateFields.executionPolicy as NonNullable<typeof updateFields.executionPolicy> | null)
|
||||
: normalizeIssueExecutionPolicy(existing.executionPolicy ?? null),
|
||||
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
|
||||
policy: nextExecutionPolicy,
|
||||
requestedStatus,
|
||||
requestedAssigneePatch: {
|
||||
assigneeAgentId:
|
||||
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
|
||||
@@ -1224,6 +1419,48 @@ export function issueRoutes(
|
||||
}
|
||||
Object.assign(updateFields, transition.patch);
|
||||
|
||||
const effectiveExecutionState = parseIssueExecutionState(
|
||||
transition.patch.executionState !== undefined ? transition.patch.executionState : existing.executionState,
|
||||
);
|
||||
const isUnauthorizedAgentStageMutation =
|
||||
req.actor.type === "agent" &&
|
||||
req.actor.agentId &&
|
||||
existing.status === "in_review" &&
|
||||
transition.workflowControlledAssignment &&
|
||||
!transition.decision &&
|
||||
effectiveExecutionState?.status === "pending" &&
|
||||
(
|
||||
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
|
||||
requestedAssigneePatchProvided
|
||||
) &&
|
||||
!executionParticipantMatchesAgent(effectiveExecutionState.currentParticipant, req.actor.agentId);
|
||||
if (isUnauthorizedAgentStageMutation) {
|
||||
const stageLabel = effectiveExecutionState.currentStageType ?? "execution";
|
||||
res.status(403).json({ error: `Only the active ${stageLabel} participant can update this stage` });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAssigneeAgentId =
|
||||
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
|
||||
const nextAssigneeUserId =
|
||||
updateFields.assigneeUserId === undefined ? existing.assigneeUserId : (updateFields.assigneeUserId as string | null);
|
||||
const assigneeWillChange =
|
||||
nextAssigneeAgentId !== existing.assigneeAgentId || nextAssigneeUserId !== existing.assigneeUserId;
|
||||
const isAgentReturningIssueToCreator =
|
||||
req.actor.type === "agent" &&
|
||||
!!req.actor.agentId &&
|
||||
existing.assigneeAgentId === req.actor.agentId &&
|
||||
nextAssigneeAgentId === null &&
|
||||
typeof nextAssigneeUserId === "string" &&
|
||||
!!existing.createdByUserId &&
|
||||
nextAssigneeUserId === existing.createdByUserId;
|
||||
|
||||
if (assigneeWillChange && !transition.workflowControlledAssignment) {
|
||||
if (!isAgentReturningIssueToCreator) {
|
||||
await assertCanAssignTasks(req, existing.companyId);
|
||||
}
|
||||
}
|
||||
|
||||
let issue;
|
||||
try {
|
||||
if (transition.decision && decisionId) {
|
||||
@@ -1291,8 +1528,9 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue;
|
||||
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
|
||||
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
|
||||
const updatedRelations = await svc.getRelationSummaries(issue.id);
|
||||
updatedRelations = await svc.getRelationSummaries(issue.id);
|
||||
issueResponse = {
|
||||
...issue,
|
||||
blockedBy: updatedRelations.blockedBy,
|
||||
@@ -1349,6 +1587,8 @@ export function issueRoutes(
|
||||
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
|
||||
const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate));
|
||||
const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate));
|
||||
const nextBlockedByRelations = updatedRelations?.blockedBy ?? [];
|
||||
const previousBlockedByRelations = existingRelations?.blockedBy ?? [];
|
||||
if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
@@ -1364,11 +1604,58 @@ export function issueRoutes(
|
||||
blockedByIssueIds: req.body.blockedByIssueIds,
|
||||
addedBlockedByIssueIds,
|
||||
removedBlockedByIssueIds,
|
||||
blockedByIssues: nextBlockedByRelations.map(summarizeIssueRelationForActivity),
|
||||
addedBlockedByIssues: nextBlockedByRelations
|
||||
.filter((relation) => addedBlockedByIssueIds.includes(relation.id))
|
||||
.map(summarizeIssueRelationForActivity),
|
||||
removedBlockedByIssues: previousBlockedByRelations
|
||||
.filter((relation) => removedBlockedByIssueIds.includes(relation.id))
|
||||
.map(summarizeIssueRelationForActivity),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reviewerChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "review");
|
||||
if (reviewerChanges.addedParticipants.length > 0 || reviewerChanges.removedParticipants.length > 0) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.reviewers_updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
participants: reviewerChanges.participants,
|
||||
addedParticipants: reviewerChanges.addedParticipants,
|
||||
removedParticipants: reviewerChanges.removedParticipants,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const approverChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "approval");
|
||||
if (approverChanges.addedParticipants.length > 0 || approverChanges.removedParticipants.length > 0) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.approvers_updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
participants: approverChanges.participants,
|
||||
addedParticipants: approverChanges.addedParticipants,
|
||||
removedParticipants: approverChanges.removedParticipants,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (issue.status === "done" && existing.status !== "done") {
|
||||
const tc = getTelemetryClient();
|
||||
if (tc && actor.agentId) {
|
||||
@@ -1414,6 +1701,16 @@ export function issueRoutes(
|
||||
existing.status === "backlog" &&
|
||||
issue.status !== "backlog" &&
|
||||
req.body.status !== undefined;
|
||||
const previousExecutionState = parseIssueExecutionState(existing.executionState);
|
||||
const nextExecutionState = parseIssueExecutionState(issue.executionState);
|
||||
const executionStageWakeup = buildExecutionStageWakeup({
|
||||
issueId: issue.id,
|
||||
previousState: previousExecutionState,
|
||||
nextState: nextExecutionState,
|
||||
interruptedRunId,
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
});
|
||||
|
||||
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
||||
void (async () => {
|
||||
@@ -1427,7 +1724,9 @@ export function issueRoutes(
|
||||
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
|
||||
};
|
||||
|
||||
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
|
||||
if (executionStageWakeup) {
|
||||
addWakeup(executionStageWakeup.agentId, executionStageWakeup.wakeup);
|
||||
} else if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
|
||||
addWakeup(issue.assigneeAgentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { joinRequests } from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { inboxDismissals, joinRequests } from "@paperclipai/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { dashboardService } from "../services/dashboard.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
function buildDismissedAtByKey(
|
||||
dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>,
|
||||
): Map<string, number> {
|
||||
return new Map(
|
||||
dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]),
|
||||
);
|
||||
}
|
||||
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarBadgeService(db);
|
||||
@@ -26,23 +34,36 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
|
||||
}
|
||||
|
||||
const joinRequestCount = canApproveJoins
|
||||
const visibleJoinRequests = canApproveJoins
|
||||
? await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.select({
|
||||
id: joinRequests.id,
|
||||
updatedAt: joinRequests.updatedAt,
|
||||
createdAt: joinRequests.createdAt,
|
||||
})
|
||||
.from(joinRequests)
|
||||
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
|
||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||
: 0;
|
||||
: [];
|
||||
|
||||
const dismissedAtByKey =
|
||||
req.actor.type === "board" && req.actor.userId
|
||||
? await db
|
||||
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
|
||||
.from(inboxDismissals)
|
||||
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId)))
|
||||
.then(buildDismissedAtByKey)
|
||||
: new Map<string, number>();
|
||||
|
||||
const badges = await svc.get(companyId, {
|
||||
joinRequests: joinRequestCount,
|
||||
dismissals: dismissedAtByKey,
|
||||
joinRequests: visibleJoinRequests,
|
||||
});
|
||||
const summary = await dashboard.summary(companyId);
|
||||
const hasFailedRuns = badges.failedRuns > 0;
|
||||
const alertsCount =
|
||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
|
||||
badges.inbox = badges.failedRuns + alertsCount + badges.joinRequests + badges.approvals;
|
||||
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
@@ -696,7 +696,14 @@ export function shouldResetTaskSessionForWake(
|
||||
if (contextSnapshot?.forceFreshSession === true) return true;
|
||||
|
||||
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
||||
if (wakeReason === "issue_assigned") return true;
|
||||
if (
|
||||
wakeReason === "issue_assigned" ||
|
||||
wakeReason === "execution_review_requested" ||
|
||||
wakeReason === "execution_approval_requested" ||
|
||||
wakeReason === "execution_changes_requested"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -714,6 +721,9 @@ function describeSessionResetReason(
|
||||
|
||||
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
||||
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
|
||||
if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested";
|
||||
if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested";
|
||||
if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -867,9 +877,8 @@ async function buildPaperclipWakePayload(input: {
|
||||
}
|
||||
| null;
|
||||
}) {
|
||||
const executionStage = parseObject(input.contextSnapshot.executionStage);
|
||||
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
||||
if (commentIds.length === 0) return null;
|
||||
|
||||
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
||||
const issueSummary =
|
||||
input.issueSummary ??
|
||||
@@ -886,23 +895,27 @@ async function buildPaperclipWakePayload(input: {
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null);
|
||||
if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null;
|
||||
|
||||
const commentRows = await input.db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
inArray(issueComments.id, commentIds),
|
||||
),
|
||||
);
|
||||
const commentRows =
|
||||
commentIds.length === 0
|
||||
? []
|
||||
: await input.db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
inArray(issueComments.id, commentIds),
|
||||
),
|
||||
);
|
||||
|
||||
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
|
||||
const comments: Array<Record<string, unknown>> = [];
|
||||
@@ -959,6 +972,7 @@ async function buildPaperclipWakePayload(input: {
|
||||
priority: issueSummary.priority,
|
||||
}
|
||||
: null,
|
||||
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
|
||||
commentIds,
|
||||
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
||||
comments,
|
||||
@@ -2159,7 +2173,7 @@ export function heartbeatService(db: Db) {
|
||||
const heartbeat = parseObject(runtimeConfig.heartbeat);
|
||||
|
||||
return {
|
||||
enabled: asBoolean(heartbeat.enabled, true),
|
||||
enabled: asBoolean(heartbeat.enabled, false),
|
||||
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
|
||||
wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true),
|
||||
maxConcurrentRuns: normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { inboxDismissals } from "@paperclipai/db";
|
||||
|
||||
export function inboxDismissalService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string, userId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(inboxDismissals)
|
||||
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId)))
|
||||
.orderBy(desc(inboxDismissals.updatedAt)),
|
||||
|
||||
dismiss: async (
|
||||
companyId: string,
|
||||
userId: string,
|
||||
itemKey: string,
|
||||
dismissedAt: Date = new Date(),
|
||||
) => {
|
||||
const now = new Date();
|
||||
const [row] = await db
|
||||
.insert(inboxDismissals)
|
||||
.values({
|
||||
companyId,
|
||||
userId,
|
||||
itemKey,
|
||||
dismissedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey],
|
||||
set: {
|
||||
dismissedAt,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
|
||||
export { heartbeatService } from "./heartbeat.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { inboxDismissalService } from "./inbox-dismissals.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { boardAuthService } from "./board-auth.js";
|
||||
export { instanceSettingsService } from "./instance-settings.js";
|
||||
|
||||
@@ -36,6 +36,7 @@ type TransitionInput = {
|
||||
type TransitionResult = {
|
||||
patch: Record<string, unknown>;
|
||||
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
|
||||
workflowControlledAssignment?: boolean;
|
||||
};
|
||||
|
||||
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
|
||||
@@ -144,6 +145,11 @@ function selectStageParticipant(
|
||||
return first ? { type: first.type, agentId: first.agentId ?? null, userId: first.userId ?? null } : null;
|
||||
}
|
||||
|
||||
function stageHasParticipant(stage: IssueExecutionStage, participant: IssueExecutionStagePrincipal | null): boolean {
|
||||
if (!participant) return false;
|
||||
return stage.participants.some((candidate) => principalsEqual(candidate, participant));
|
||||
}
|
||||
|
||||
function patchForPrincipal(principal: IssueExecutionStagePrincipal | null) {
|
||||
if (!principal) {
|
||||
return { assigneeAgentId: null, assigneeUserId: null };
|
||||
@@ -198,14 +204,49 @@ function buildChangesRequestedState(previous: IssueExecutionState, currentStage:
|
||||
};
|
||||
}
|
||||
|
||||
function buildPendingStagePatch(input: {
|
||||
patch: Record<string, unknown>;
|
||||
previous: IssueExecutionState | null;
|
||||
policy: IssueExecutionPolicy;
|
||||
stage: IssueExecutionStage;
|
||||
participant: IssueExecutionStagePrincipal;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
}) {
|
||||
input.patch.status = "in_review";
|
||||
Object.assign(input.patch, patchForPrincipal(input.participant));
|
||||
input.patch.executionState = buildPendingState({
|
||||
previous: input.previous,
|
||||
stage: input.stage,
|
||||
stageIndex: input.policy.stages.findIndex((candidate) => candidate.id === input.stage.id),
|
||||
participant: input.participant,
|
||||
returnAssignee: input.returnAssignee,
|
||||
});
|
||||
}
|
||||
|
||||
function clearExecutionStatePatch(input: {
|
||||
patch: Record<string, unknown>;
|
||||
issueStatus: string;
|
||||
requestedStatus?: string;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
}) {
|
||||
input.patch.executionState = null;
|
||||
if (input.requestedStatus === undefined && input.issueStatus === "in_review" && input.returnAssignee) {
|
||||
input.patch.status = "in_progress";
|
||||
Object.assign(input.patch, patchForPrincipal(input.returnAssignee));
|
||||
}
|
||||
}
|
||||
|
||||
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentAssignee = assigneePrincipal(input.issue);
|
||||
const actor = actorPrincipal(input.actor);
|
||||
const requestedAssigneePatchProvided =
|
||||
input.requestedAssigneePatch.assigneeAgentId !== undefined || input.requestedAssigneePatch.assigneeUserId !== undefined;
|
||||
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
|
||||
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
|
||||
const requestedStatus = input.requestedStatus;
|
||||
const activeStage = currentStage && existingState?.status === PENDING_STATUS ? currentStage : null;
|
||||
|
||||
if (!input.policy) {
|
||||
if (existingState) {
|
||||
@@ -228,90 +269,159 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (currentStage && input.issue.status === "in_review") {
|
||||
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) {
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
|
||||
}
|
||||
return { patch };
|
||||
if (existingState?.currentStageId && !currentStage) {
|
||||
clearExecutionStatePatch({
|
||||
patch,
|
||||
issueStatus: input.issue.status,
|
||||
requestedStatus,
|
||||
returnAssignee: existingState.returnAssignee,
|
||||
});
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (activeStage) {
|
||||
const currentParticipant =
|
||||
existingState?.currentParticipant ??
|
||||
selectStageParticipant(activeStage, {
|
||||
exclude: existingState?.returnAssignee ?? null,
|
||||
});
|
||||
if (!currentParticipant) {
|
||||
throw unprocessable(`No eligible ${activeStage.type} participant is configured for this issue`);
|
||||
}
|
||||
|
||||
if (requestedStatus === "done") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Approving a review or approval stage requires a comment");
|
||||
}
|
||||
const approvedState = buildCompletedState(existingState, currentStage);
|
||||
const nextStage = nextPendingStage(
|
||||
input.policy,
|
||||
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
|
||||
);
|
||||
|
||||
if (!nextStage) {
|
||||
patch.executionState = approvedState;
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const participant = selectStageParticipant(nextStage, {
|
||||
preferred: explicitAssignee,
|
||||
if (!stageHasParticipant(activeStage, currentParticipant)) {
|
||||
const participant = selectStageParticipant(activeStage, {
|
||||
preferred: explicitAssignee ?? existingState?.currentParticipant ?? null,
|
||||
exclude: existingState?.returnAssignee ?? null,
|
||||
});
|
||||
if (!participant) {
|
||||
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
|
||||
clearExecutionStatePatch({
|
||||
patch,
|
||||
issueStatus: input.issue.status,
|
||||
requestedStatus,
|
||||
returnAssignee: existingState?.returnAssignee ?? null,
|
||||
});
|
||||
return { patch };
|
||||
}
|
||||
|
||||
patch.status = "in_review";
|
||||
Object.assign(patch, patchForPrincipal(participant));
|
||||
patch.executionState = buildPendingState({
|
||||
previous: approvedState,
|
||||
stage: nextStage,
|
||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
|
||||
buildPendingStagePatch({
|
||||
patch,
|
||||
previous: existingState,
|
||||
policy: input.policy,
|
||||
stage: activeStage,
|
||||
participant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Requesting changes requires a comment");
|
||||
if (principalsEqual(currentParticipant, actor)) {
|
||||
if (requestedStatus === "done") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Approving a review or approval stage requires a comment");
|
||||
}
|
||||
const approvedState = buildCompletedState(existingState, activeStage);
|
||||
const nextStage = nextPendingStage(
|
||||
input.policy,
|
||||
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
|
||||
);
|
||||
|
||||
if (!nextStage) {
|
||||
patch.executionState = approvedState;
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: activeStage.id,
|
||||
stageType: activeStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const participant = selectStageParticipant(nextStage, {
|
||||
preferred: explicitAssignee,
|
||||
exclude: existingState?.returnAssignee ?? null,
|
||||
});
|
||||
if (!participant) {
|
||||
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
|
||||
}
|
||||
|
||||
buildPendingStagePatch({
|
||||
patch,
|
||||
previous: approvedState,
|
||||
policy: input.policy,
|
||||
stage: nextStage,
|
||||
participant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: activeStage.id,
|
||||
stageType: activeStage.type,
|
||||
outcome: "approved",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
if (!existingState?.returnAssignee) {
|
||||
throw unprocessable("This execution stage has no return assignee");
|
||||
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable("Requesting changes requires a comment");
|
||||
}
|
||||
if (!existingState?.returnAssignee) {
|
||||
throw unprocessable("This execution stage has no return assignee");
|
||||
}
|
||||
patch.status = "in_progress";
|
||||
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
||||
patch.executionState = buildChangesRequestedState(existingState, activeStage);
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: activeStage.id,
|
||||
stageType: activeStage.type,
|
||||
outcome: "changes_requested",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
patch.status = "in_progress";
|
||||
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
||||
patch.executionState = buildChangesRequestedState(existingState, currentStage);
|
||||
}
|
||||
|
||||
if (
|
||||
input.issue.status !== "in_review" ||
|
||||
!principalsEqual(currentAssignee, currentParticipant) ||
|
||||
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) ||
|
||||
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
|
||||
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant))
|
||||
) {
|
||||
buildPendingStagePatch({
|
||||
patch,
|
||||
previous: existingState,
|
||||
policy: input.policy,
|
||||
stage: activeStage,
|
||||
participant: currentParticipant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
decision: {
|
||||
stageId: currentStage.id,
|
||||
stageType: currentStage.type,
|
||||
outcome: "changes_requested",
|
||||
body: input.commentBody.trim(),
|
||||
},
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (requestedStatus !== "done") {
|
||||
const shouldStartWorkflow =
|
||||
requestedStatus === "done" ||
|
||||
requestedStatus === "in_review";
|
||||
|
||||
if (!shouldStartWorkflow) {
|
||||
return { patch };
|
||||
}
|
||||
|
||||
@@ -333,14 +443,16 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
|
||||
}
|
||||
|
||||
patch.status = "in_review";
|
||||
Object.assign(patch, patchForPrincipal(participant));
|
||||
patch.executionState = buildPendingState({
|
||||
buildPendingStagePatch({
|
||||
patch,
|
||||
previous: existingState,
|
||||
policy: input.policy,
|
||||
stage: pendingStage,
|
||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
|
||||
participant,
|
||||
returnAssignee,
|
||||
});
|
||||
return { patch };
|
||||
return {
|
||||
patch,
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -675,6 +675,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||
executionWorkspaceSettings?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
|
||||
const title = interpolateRoutineTemplate(input.routine.title, resolvedVariables) ?? input.routine.title;
|
||||
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
|
||||
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
|
||||
const run = await db.transaction(async (tx) => {
|
||||
@@ -748,7 +749,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||
projectId: input.routine.projectId,
|
||||
goalId: input.routine.goalId,
|
||||
parentId: input.routine.parentIssueId,
|
||||
title: input.routine.title,
|
||||
title,
|
||||
description,
|
||||
status: "todo",
|
||||
priority: input.routine.priority,
|
||||
@@ -996,7 +997,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||
const variables = syncRoutineVariablesWithTemplate(
|
||||
input.description,
|
||||
[input.title, input.description],
|
||||
sanitizeRoutineVariableInputs(input.variables),
|
||||
);
|
||||
assertRoutineVariableDefinitions(variables);
|
||||
@@ -1029,9 +1030,10 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||
if (!existing) return null;
|
||||
const nextProjectId = patch.projectId ?? existing.projectId;
|
||||
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
|
||||
const nextTitle = patch.title ?? existing.title;
|
||||
const nextDescription = patch.description === undefined ? existing.description : patch.description;
|
||||
const nextVariables = syncRoutineVariablesWithTemplate(
|
||||
nextDescription,
|
||||
[nextTitle, nextDescription],
|
||||
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
||||
);
|
||||
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
|
||||
@@ -1060,7 +1062,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||
projectId: nextProjectId,
|
||||
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
|
||||
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
|
||||
title: patch.title ?? existing.title,
|
||||
title: nextTitle,
|
||||
description: nextDescription,
|
||||
assigneeAgentId: nextAssigneeAgentId,
|
||||
priority: patch.priority ?? existing.priority,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, not } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
|
||||
import type { SidebarBadges } from "@paperclipai/shared";
|
||||
@@ -6,14 +6,34 @@ import type { SidebarBadges } from "@paperclipai/shared";
|
||||
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
||||
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
|
||||
|
||||
function normalizeTimestamp(value: Date | string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
function isDismissed(
|
||||
dismissedAtByKey: ReadonlyMap<string, number>,
|
||||
itemKey: string,
|
||||
activityAt: Date | string | null | undefined,
|
||||
) {
|
||||
const dismissedAt = dismissedAtByKey.get(itemKey);
|
||||
if (dismissedAt == null) return false;
|
||||
return dismissedAt >= normalizeTimestamp(activityAt);
|
||||
}
|
||||
|
||||
export function sidebarBadgeService(db: Db) {
|
||||
return {
|
||||
get: async (
|
||||
companyId: string,
|
||||
extra?: { joinRequests?: number; unreadTouchedIssues?: number },
|
||||
extra?: {
|
||||
dismissals?: ReadonlyMap<string, number>;
|
||||
joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>;
|
||||
unreadTouchedIssues?: number;
|
||||
},
|
||||
): Promise<SidebarBadges> => {
|
||||
const actionableApprovals = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.select({ id: approvals.id, updatedAt: approvals.updatedAt })
|
||||
.from(approvals)
|
||||
.where(
|
||||
and(
|
||||
@@ -21,11 +41,15 @@ export function sidebarBadgeService(db: Db) {
|
||||
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
.then((rows) =>
|
||||
rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length
|
||||
);
|
||||
|
||||
const latestRunByAgent = await db
|
||||
.selectDistinctOn([heartbeatRuns.agentId], {
|
||||
id: heartbeatRuns.id,
|
||||
runStatus: heartbeatRuns.status,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
||||
@@ -39,10 +63,17 @@ export function sidebarBadgeService(db: Db) {
|
||||
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
|
||||
|
||||
const failedRuns = latestRunByAgent.filter((row) =>
|
||||
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus),
|
||||
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus)
|
||||
&& !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt),
|
||||
).length;
|
||||
|
||||
const joinRequests = extra?.joinRequests ?? 0;
|
||||
const joinRequests = (extra?.joinRequests ?? []).filter((row) =>
|
||||
!isDismissed(
|
||||
extra?.dismissals ?? new Map(),
|
||||
`join:${row.id}`,
|
||||
row.updatedAt ?? row.createdAt,
|
||||
)
|
||||
).length;
|
||||
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
|
||||
return {
|
||||
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -157,6 +157,16 @@ function findWorkspaceRoot(startCwd: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function isLinkedGitWorktreeCheckout(rootDir: string) {
|
||||
const gitMetadataPath = path.join(rootDir, ".git");
|
||||
if (!existsSync(gitMetadataPath)) return false;
|
||||
|
||||
const stat = lstatSync(gitMetadataPath);
|
||||
if (!stat.isFile()) return false;
|
||||
|
||||
return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:");
|
||||
}
|
||||
|
||||
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
||||
const packagePaths = new Map<string, string>();
|
||||
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
||||
@@ -228,6 +238,7 @@ export async function ensureServerWorkspaceLinksCurrent(
|
||||
) {
|
||||
const workspaceRoot = findWorkspaceRoot(startCwd);
|
||||
if (!workspaceRoot) return;
|
||||
if (!isLinkedGitWorktreeCheckout(workspaceRoot)) return;
|
||||
|
||||
const mismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
|
||||
if (mismatches.length === 0) return;
|
||||
|
||||
@@ -27,6 +27,8 @@ Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli
|
||||
|
||||
Follow these steps every time you wake up:
|
||||
|
||||
**Scoped-wake fast path.** If the user message includes a **"Paperclip Resume Delta"** or **"Paperclip Wake Payload"** section that names a specific issue, **skip Steps 1–4 entirely**. Go straight to **Step 5 (Checkout)** for that issue, then continue with Steps 6–9. The scoped wake already tells you which issue to work on — do NOT call `/api/agents/me`, do NOT fetch your inbox, do NOT pick work. Just checkout, read the wake context, do the work, and update.
|
||||
|
||||
**Step 1 — Identity.** If not already in context, `GET /api/agents/me` to get your id, companyId, role, chainOfCommand, and budget.
|
||||
|
||||
**Step 2 — Approval follow-up (when triggered).** If `PAPERCLIP_APPROVAL_ID` is set (or wake reason indicates approval resolution), review the approval first:
|
||||
|
||||
@@ -161,6 +161,8 @@ function boardRoutes() {
|
||||
<Route path="routines" element={<Routines />} />
|
||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="goals" element={<Goals />} />
|
||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
||||
@@ -349,6 +351,8 @@ export function App() {
|
||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path=":companyPrefix" element={<Layout />}>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { InboxDismissal } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const inboxDismissalsApi = {
|
||||
list: (companyId: string) => api.get<InboxDismissal[]>(`/companies/${companyId}/inbox-dismissals`),
|
||||
dismiss: (companyId: string, itemKey: string) =>
|
||||
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, { itemKey }),
|
||||
};
|
||||
@@ -15,4 +15,5 @@ export { dashboardApi } from "./dashboard";
|
||||
export { heartbeatsApi } from "./heartbeats";
|
||||
export { instanceSettingsApi } from "./instanceSettings";
|
||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||
export { inboxDismissalsApi } from "./inboxDismissals";
|
||||
export { companySkillsApi } from "./companySkills";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
api: mockApi,
|
||||
}));
|
||||
|
||||
import { issuesApi } from "./issues";
|
||||
|
||||
describe("issuesApi.list", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("passes parentId through to the company issues endpoint", async () => {
|
||||
await issuesApi.list("company-1", { parentId: "issue-parent-1", limit: 25 });
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/companies/company-1/issues?parentId=issue-parent-1&limit=25",
|
||||
);
|
||||
});
|
||||
});
|
||||
+17
-1
@@ -24,6 +24,7 @@ export const issuesApi = {
|
||||
filters?: {
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
parentId?: string;
|
||||
assigneeAgentId?: string;
|
||||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
@@ -42,6 +43,7 @@ export const issuesApi = {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.status) params.set("status", filters.status);
|
||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters?.parentId) params.set("parentId", filters.parentId);
|
||||
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
|
||||
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
||||
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||
@@ -80,7 +82,21 @@ export const issuesApi = {
|
||||
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
|
||||
}),
|
||||
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
|
||||
listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`),
|
||||
listComments: (
|
||||
id: string,
|
||||
filters?: {
|
||||
after?: string;
|
||||
order?: "asc" | "desc";
|
||||
limit?: number;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.after) params.set("after", filters.after);
|
||||
if (filters?.order) params.set("order", filters.order);
|
||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||
const qs = params.toString();
|
||||
return api.get<IssueComment[]>(`/issues/${id}/comments${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -2,72 +2,9 @@ import { Link } from "@/lib/router";
|
||||
import { Identity } from "./Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
import { formatActivityVerb } from "../lib/activity-format";
|
||||
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
|
||||
|
||||
const ACTION_VERBS: Record<string, string> = {
|
||||
"issue.created": "created",
|
||||
"issue.updated": "updated",
|
||||
"issue.checked_out": "checked out",
|
||||
"issue.released": "released",
|
||||
"issue.comment_added": "commented on",
|
||||
"issue.attachment_added": "attached file to",
|
||||
"issue.attachment_removed": "removed attachment from",
|
||||
"issue.document_created": "created document for",
|
||||
"issue.document_updated": "updated document on",
|
||||
"issue.document_deleted": "deleted document from",
|
||||
"issue.commented": "commented on",
|
||||
"issue.deleted": "deleted",
|
||||
"agent.created": "created",
|
||||
"agent.updated": "updated",
|
||||
"agent.paused": "paused",
|
||||
"agent.resumed": "resumed",
|
||||
"agent.terminated": "terminated",
|
||||
"agent.key_created": "created API key for",
|
||||
"agent.budget_updated": "updated budget for",
|
||||
"agent.runtime_session_reset": "reset session for",
|
||||
"heartbeat.invoked": "invoked heartbeat for",
|
||||
"heartbeat.cancelled": "cancelled heartbeat for",
|
||||
"approval.created": "requested approval",
|
||||
"approval.approved": "approved",
|
||||
"approval.rejected": "rejected",
|
||||
"project.created": "created",
|
||||
"project.updated": "updated",
|
||||
"project.deleted": "deleted",
|
||||
"goal.created": "created",
|
||||
"goal.updated": "updated",
|
||||
"goal.deleted": "deleted",
|
||||
"cost.reported": "reported cost for",
|
||||
"cost.recorded": "recorded cost for",
|
||||
"company.created": "created company",
|
||||
"company.updated": "updated company",
|
||||
"company.archived": "archived",
|
||||
"company.budget_updated": "updated budget for",
|
||||
};
|
||||
|
||||
function humanizeValue(value: unknown): string {
|
||||
if (typeof value !== "string") return String(value ?? "none");
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function formatVerb(action: string, details?: Record<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
if (details.status !== undefined) {
|
||||
const from = previous.status;
|
||||
return from
|
||||
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
|
||||
: `changed status to ${humanizeValue(details.status)} on`;
|
||||
}
|
||||
if (details.priority !== undefined) {
|
||||
const from = previous.priority;
|
||||
return from
|
||||
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
|
||||
: `changed priority to ${humanizeValue(details.priority)} on`;
|
||||
}
|
||||
}
|
||||
return ACTION_VERBS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
|
||||
switch (entityType) {
|
||||
case "issue": return `/issues/${name ?? entityId}`;
|
||||
@@ -88,7 +25,7 @@ interface ActivityRowProps {
|
||||
}
|
||||
|
||||
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||
const verb = formatVerb(event.action, event.details);
|
||||
const verb = formatActivityVerb(event.action, event.details, { agentMap });
|
||||
|
||||
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
||||
const heartbeatAgentId = isHeartbeatEvent
|
||||
|
||||
@@ -923,14 +923,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
<ToggleWithNumber
|
||||
label="Heartbeat on interval"
|
||||
hint={help.heartbeatInterval}
|
||||
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
checked={eff("heartbeat", "enabled", heartbeat.enabled === true)}
|
||||
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
|
||||
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
|
||||
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
|
||||
numberLabel="sec"
|
||||
numberPrefix="Run heartbeat every"
|
||||
numberHint={help.intervalSec}
|
||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)}
|
||||
/>
|
||||
</div>
|
||||
<CollapsibleSection
|
||||
|
||||
@@ -61,12 +61,26 @@ vi.mock("@/plugins/slots", () => ({
|
||||
|
||||
describe("CommentThread", () => {
|
||||
let container: HTMLDivElement;
|
||||
let writeTextMock: ReturnType<typeof vi.fn>;
|
||||
let execCommandMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z"));
|
||||
writeTextMock = vi.fn(async () => {});
|
||||
execCommandMock = vi.fn(() => true);
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: writeTextMock,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.execCommand = execCommandMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -234,4 +248,59 @@ describe("CommentThread", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a larger copy control with feedback and a clipboard fallback", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread
|
||||
comments={[{
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "Hello from the comment body",
|
||||
createdAt: new Date("2026-03-11T11:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T11:00:00.000Z"),
|
||||
}]}
|
||||
onAdd={async () => {}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const copyButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.getAttribute("aria-label") === "Copy comment as markdown",
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
expect(copyButton).toBeDefined();
|
||||
expect(copyButton?.className).toContain("min-h-8");
|
||||
expect(copyButton?.textContent).toContain("Copy");
|
||||
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
copyButton?.click();
|
||||
});
|
||||
|
||||
expect(writeTextMock).not.toHaveBeenCalled();
|
||||
expect(execCommandMock).toHaveBeenCalledWith("copy");
|
||||
expect(copyButton?.textContent).toContain("Copied");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
expect(copyButton?.textContent).toContain("Copy");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,21 +210,71 @@ function runStatusClass(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTextWithFallback(text: string) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
textarea.select();
|
||||
const success = document.execCommand("copy");
|
||||
if (!success) throw new Error("execCommand copy failed");
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
function CopyMarkdownButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const label = status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : "Copy";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy as markdown"
|
||||
className={cn(
|
||||
"inline-flex min-h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors",
|
||||
status === "copied"
|
||||
? "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-300"
|
||||
: status === "failed"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
aria-label="Copy comment as markdown"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
void copyTextWithFallback(text)
|
||||
.then(() => setStatus("copied"))
|
||||
.catch(() => setStatus("failed"));
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setStatus("idle");
|
||||
timeoutRef.current = null;
|
||||
}, 1500);
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{status === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
<span className="sm:hidden">{label}</span>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { act, createRef, forwardRef, useImperativeHandle } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||
|
||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||
markdownEditorFocusMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const { threadMessagesMock } = vi.hoisted(() => ({
|
||||
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
|
||||
}));
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ThreadPrimitive: {
|
||||
@@ -17,7 +25,7 @@ vi.mock("@assistant-ui/react", () => ({
|
||||
<div data-testid="thread-viewport" className={className}>{children}</div>
|
||||
),
|
||||
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Messages: () => <div data-testid="thread-messages" />,
|
||||
Messages: () => threadMessagesMock(),
|
||||
},
|
||||
MessagePrimitive: {
|
||||
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
@@ -48,22 +56,34 @@ vi.mock("./MarkdownBody", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownEditor", () => ({
|
||||
MarkdownEditor: ({
|
||||
MarkdownEditor: forwardRef(({
|
||||
value = "",
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
contentClassName,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="Issue chat editor"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
/>
|
||||
),
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}, ref) => {
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: markdownEditorFocusMock,
|
||||
}));
|
||||
|
||||
return (
|
||||
<textarea
|
||||
aria-label="Issue chat editor"
|
||||
data-class-name={className}
|
||||
data-content-class-name={contentClassName}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
@@ -100,11 +120,14 @@ describe("IssueChatThread", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
localStorage.clear();
|
||||
threadMessagesMock.mockImplementation(() => <div data-testid="thread-messages" />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
markdownEditorFocusMock.mockReset();
|
||||
threadMessagesMock.mockReset();
|
||||
});
|
||||
|
||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
||||
@@ -172,6 +195,48 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a safe transcript warning when assistant-ui throws during message rendering", () => {
|
||||
const root = createRoot(container);
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
threadMessagesMock.mockImplementation(() => {
|
||||
throw new Error("tapClientLookup: Index 8 out of bounds (length: 8)");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[{
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: "Agent summary",
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
}]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Chat renderer hit an internal state error.");
|
||||
expect(container.textContent).toContain("Agent summary");
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("stores and restores the composer draft per issue key", () => {
|
||||
vi.useFakeTimers();
|
||||
const root = createRoot(container);
|
||||
@@ -240,6 +305,88 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the composer inline with bottom breathing room and a capped editor height", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
expect(composer?.className).not.toContain("sticky");
|
||||
expect(composer?.className).not.toContain("bottom-0");
|
||||
expect(composer?.className).toContain("pb-[calc(env(safe-area-inset-bottom)+1.5rem)]");
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||
expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]");
|
||||
expect(editor?.dataset.contentClassName).toContain("overflow-y-auto");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a composer focus handle that forwards to the editor", () => {
|
||||
const root = createRoot(container);
|
||||
const composerRef = createRef<{ focus: () => void }>();
|
||||
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
const requestAnimationFrameMock = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
composerRef={composerRef}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||
expect(composerRef.current).not.toBeNull();
|
||||
expect(composer).not.toBeNull();
|
||||
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
composer!.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
act(() => {
|
||||
composerRef.current?.focus();
|
||||
});
|
||||
|
||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "end" });
|
||||
expect(scrollByMock).toHaveBeenCalledWith({ top: 96, behavior: "smooth" });
|
||||
expect(markdownEditorFocusMock).toHaveBeenCalledTimes(1);
|
||||
scrollByMock.mockRestore();
|
||||
requestAnimationFrameMock.mockRestore();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
||||
expect(resolveAssistantMessageFoldedState({
|
||||
messageId: "message-1",
|
||||
|
||||
@@ -8,7 +8,21 @@ import {
|
||||
useMessage,
|
||||
} from "@assistant-ui/react";
|
||||
import type { ToolCallMessagePart } from "@assistant-ui/react";
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
createContext,
|
||||
Component,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type ErrorInfo,
|
||||
type Ref,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Link, useLocation } from "@/lib/router";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -65,7 +79,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
|
||||
interface IssueChatMessageContext {
|
||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||
@@ -80,6 +94,7 @@ interface IssueChatMessageContext {
|
||||
) => Promise<void>;
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
}
|
||||
|
||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||
@@ -144,6 +159,24 @@ interface CommentReassignment {
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
export interface IssueChatComposerHandle {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface IssueChatComposerProps {
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
onAttachImage?: (file: File) => Promise<void>;
|
||||
draftKey?: string;
|
||||
enableReassign?: boolean;
|
||||
reassignOptions?: InlineEntityOption[];
|
||||
currentAssigneeValue?: string;
|
||||
suggestedAssigneeValue?: string;
|
||||
mentions?: MentionOption[];
|
||||
agentMap?: Map<string, Agent>;
|
||||
composerDisabledReason?: string | null;
|
||||
issueStatus?: string;
|
||||
}
|
||||
|
||||
interface IssueChatThreadProps {
|
||||
comments: IssueChatComment[];
|
||||
feedbackVotes?: FeedbackVote[];
|
||||
@@ -184,9 +217,151 @@ interface IssueChatThreadProps {
|
||||
includeSucceededRunsWithoutOutput?: boolean;
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
composerRef?: Ref<IssueChatComposerHandle>;
|
||||
}
|
||||
|
||||
type IssueChatErrorBoundaryProps = {
|
||||
resetKey: string;
|
||||
messages: readonly import("@assistant-ui/react").ThreadMessage[];
|
||||
emptyMessage: string;
|
||||
variant: "full" | "embedded";
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type IssueChatErrorBoundaryState = {
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
class IssueChatErrorBoundary extends Component<IssueChatErrorBoundaryProps, IssueChatErrorBoundaryState> {
|
||||
override state: IssueChatErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): IssueChatErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
||||
console.error("Issue chat renderer failed; falling back to safe transcript view", {
|
||||
error,
|
||||
info: info.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
override componentDidUpdate(prevProps: IssueChatErrorBoundaryProps): void {
|
||||
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<IssueChatFallbackThread
|
||||
messages={this.props.messages}
|
||||
emptyMessage={this.props.emptyMessage}
|
||||
variant={this.props.variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessage) {
|
||||
const custom = message.metadata?.custom as Record<string, unknown> | undefined;
|
||||
if (typeof custom?.["authorName"] === "string") return custom["authorName"];
|
||||
if (typeof custom?.["runAgentName"] === "string") return custom["runAgentName"];
|
||||
if (message.role === "assistant") return "Agent";
|
||||
if (message.role === "user") return "You";
|
||||
return "System";
|
||||
}
|
||||
|
||||
function fallbackTextParts(message: import("@assistant-ui/react").ThreadMessage) {
|
||||
const contentLines: string[] = [];
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
if (part.text.trim().length > 0) contentLines.push(part.text);
|
||||
continue;
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
const lines = [`Tool: ${part.toolName}`];
|
||||
if (part.argsText?.trim()) lines.push(`Args:\n${part.argsText}`);
|
||||
if (typeof part.result === "string" && part.result.trim()) lines.push(`Result:\n${part.result}`);
|
||||
contentLines.push(lines.join("\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
const custom = message.metadata?.custom as Record<string, unknown> | undefined;
|
||||
if (contentLines.length === 0 && typeof custom?.["waitingText"] === "string" && custom["waitingText"].trim()) {
|
||||
contentLines.push(custom["waitingText"]);
|
||||
}
|
||||
return contentLines;
|
||||
}
|
||||
|
||||
function IssueChatFallbackThread({
|
||||
messages,
|
||||
emptyMessage,
|
||||
variant,
|
||||
}: {
|
||||
messages: readonly import("@assistant-ui/react").ThreadMessage[];
|
||||
emptyMessage: string;
|
||||
variant: "full" | "embedded";
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||||
<div className="rounded-xl border border-amber-300/60 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Chat renderer hit an internal state error.</p>
|
||||
<p className="text-xs opacity-80">
|
||||
Showing a safe fallback transcript instead of crashing the issues page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messages.length === 0 ? (
|
||||
<div className={cn(
|
||||
"text-center text-sm text-muted-foreground",
|
||||
variant === "embedded"
|
||||
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
|
||||
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
|
||||
)}>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||||
{messages.map((message) => {
|
||||
const lines = fallbackTextParts(message);
|
||||
return (
|
||||
<div key={message.id} className="rounded-xl border border-border/60 bg-card/70 px-4 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">{fallbackAuthorLabel(message)}</span>
|
||||
{message.createdAt ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{commentDateLabel(message.createdAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{lines.length > 0 ? lines.map((line, index) => (
|
||||
<MarkdownBody key={`${message.id}:fallback:${index}`}>{line}</MarkdownBody>
|
||||
)) : (
|
||||
<p className="text-sm text-muted-foreground">No message content.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
||||
|
||||
function toIsoString(value: string | Date | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
@@ -246,8 +421,9 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||
}
|
||||
|
||||
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||
const { onImageClick } = useContext(IssueChatCtx);
|
||||
return (
|
||||
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined}>
|
||||
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined} onImageClick={onImageClick}>
|
||||
{text}
|
||||
</MarkdownBody>
|
||||
);
|
||||
@@ -815,25 +991,26 @@ function IssueChatAssistantMessage() {
|
||||
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
||||
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
||||
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
||||
const isFoldable = !isRunning && hasCoT && !!chainOfThoughtLabel;
|
||||
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||||
const [folded, setFolded] = useState(isFoldable);
|
||||
const previousMessageIdRef = useRef<string | null>(message.id);
|
||||
const previousIsFoldableRef = useRef(isFoldable);
|
||||
const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable });
|
||||
|
||||
useEffect(() => {
|
||||
// Derive fold state synchronously during render (not in useEffect) so the
|
||||
// browser never paints the un-folded intermediate state — prevents the
|
||||
// visible "jump" when loading a page with already-folded work sections.
|
||||
if (message.id !== prevFoldKey.messageId || isFoldable !== prevFoldKey.isFoldable) {
|
||||
const nextFolded = resolveAssistantMessageFoldedState({
|
||||
messageId: message.id,
|
||||
currentFolded: folded,
|
||||
isFoldable,
|
||||
previousMessageId: previousMessageIdRef.current,
|
||||
previousIsFoldable: previousIsFoldableRef.current,
|
||||
previousMessageId: prevFoldKey.messageId,
|
||||
previousIsFoldable: prevFoldKey.isFoldable,
|
||||
});
|
||||
previousMessageIdRef.current = message.id;
|
||||
previousIsFoldableRef.current = isFoldable;
|
||||
setPrevFoldKey({ messageId: message.id, isFoldable });
|
||||
if (nextFolded !== folded) {
|
||||
setFolded(nextFolded);
|
||||
}
|
||||
}, [folded, isFoldable, message.id]);
|
||||
}
|
||||
|
||||
const handleVote = async (
|
||||
vote: FeedbackVoteValue,
|
||||
@@ -896,8 +1073,15 @@ function IssueChatAssistantMessage() {
|
||||
}}
|
||||
/>
|
||||
{message.content.length === 0 && waitingText ? (
|
||||
<div className="rounded-sm bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
{waitingText}
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-1 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||||
{agentIcon ? (
|
||||
<AgentIcon icon={agentIcon} className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<span className="shimmer-text">{waitingText}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{notices.length > 0 ? (
|
||||
@@ -1350,7 +1534,7 @@ function IssueChatSystemMessage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function IssueChatComposer({
|
||||
const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerProps>(function IssueChatComposer({
|
||||
onImageUpload,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
@@ -1362,19 +1546,7 @@ function IssueChatComposer({
|
||||
agentMap,
|
||||
composerDisabledReason = null,
|
||||
issueStatus,
|
||||
}: {
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
onAttachImage?: (file: File) => Promise<void>;
|
||||
draftKey?: string;
|
||||
enableReassign?: boolean;
|
||||
reassignOptions?: InlineEntityOption[];
|
||||
currentAssigneeValue?: string;
|
||||
suggestedAssigneeValue?: string;
|
||||
mentions?: MentionOption[];
|
||||
agentMap?: Map<string, Agent>;
|
||||
composerDisabledReason?: string | null;
|
||||
issueStatus?: string;
|
||||
}) {
|
||||
}, forwardedRef) {
|
||||
const api = useAui();
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
|
||||
@@ -1384,6 +1556,7 @@ function IssueChatComposer({
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1409,6 +1582,16 @@ function IssueChatComposer({
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => {
|
||||
composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
},
|
||||
}), []);
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || submitting) return;
|
||||
@@ -1477,7 +1660,11 @@ function IssueChatComposer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
ref={composerContainerRef}
|
||||
data-testid="issue-chat-composer"
|
||||
className="space-y-3 pt-4 pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
@@ -1486,10 +1673,11 @@ function IssueChatComposer({
|
||||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
imageUploadHandler={onImageUpload}
|
||||
contentClassName="min-h-[72px] text-sm"
|
||||
bordered
|
||||
contentClassName="min-h-[72px] max-h-[28dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center justify-end gap-3">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
{(onImageUpload || onAttachImage) ? (
|
||||
<div className="mr-auto flex items-center gap-3">
|
||||
<input
|
||||
@@ -1566,7 +1754,7 @@ function IssueChatComposer({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function IssueChatThread({
|
||||
comments,
|
||||
@@ -1604,6 +1792,8 @@ export function IssueChatThread({
|
||||
includeSucceededRunsWithoutOutput = false,
|
||||
onInterruptQueued,
|
||||
interruptingQueuedRunId = null,
|
||||
onImageClick,
|
||||
composerRef,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
@@ -1731,6 +1921,7 @@ export function IssueChatThread({
|
||||
onVote,
|
||||
onInterruptQueued,
|
||||
interruptingQueuedRunId,
|
||||
onImageClick,
|
||||
}),
|
||||
[
|
||||
feedbackVoteByTargetId,
|
||||
@@ -1741,6 +1932,7 @@ export function IssueChatThread({
|
||||
onVote,
|
||||
onInterruptQueued,
|
||||
interruptingQueuedRunId,
|
||||
onImageClick,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1758,6 +1950,10 @@ export function IssueChatThread({
|
||||
?? (variant === "embedded"
|
||||
? "No run output yet."
|
||||
: "This issue conversation is empty. Start with a message below.");
|
||||
const errorBoundaryResetKey = useMemo(
|
||||
() => messages.map((message) => `${message.id}:${message.role}:${message.content.length}:${message.status?.type ?? "none"}`).join("|"),
|
||||
[messages],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
@@ -1775,25 +1971,33 @@ export function IssueChatThread({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ThreadPrimitive.Root className="">
|
||||
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
||||
<ThreadPrimitive.Empty>
|
||||
<div className={cn(
|
||||
"text-center text-sm text-muted-foreground",
|
||||
variant === "embedded"
|
||||
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
|
||||
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
|
||||
)}>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
</ThreadPrimitive.Empty>
|
||||
<ThreadPrimitive.Messages components={components} />
|
||||
<div ref={bottomAnchorRef} />
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
<IssueChatErrorBoundary
|
||||
resetKey={errorBoundaryResetKey}
|
||||
messages={messages}
|
||||
emptyMessage={resolvedEmptyMessage}
|
||||
variant={variant}
|
||||
>
|
||||
<ThreadPrimitive.Root className="">
|
||||
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
||||
<ThreadPrimitive.Empty>
|
||||
<div className={cn(
|
||||
"text-center text-sm text-muted-foreground",
|
||||
variant === "embedded"
|
||||
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
|
||||
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
|
||||
)}>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
</ThreadPrimitive.Empty>
|
||||
<ThreadPrimitive.Messages components={components} />
|
||||
<div ref={bottomAnchorRef} />
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
</IssueChatErrorBoundary>
|
||||
|
||||
{showComposer ? (
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Columns3 } from "lucide-react";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import type { InboxIssueColumn } from "../lib/inbox";
|
||||
import { cn } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Identity } from "./Identity";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
export const issueTrailingColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
|
||||
|
||||
const issueColumnLabels: Record<InboxIssueColumn, string> = {
|
||||
status: "Status",
|
||||
id: "ID",
|
||||
assignee: "Assignee",
|
||||
project: "Project",
|
||||
workspace: "Workspace",
|
||||
parent: "Parent issue",
|
||||
labels: "Tags",
|
||||
updated: "Last updated",
|
||||
};
|
||||
|
||||
const issueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
||||
status: "Issue state chip on the left edge.",
|
||||
id: "Ticket identifier like PAP-1009.",
|
||||
assignee: "Assigned agent or board user.",
|
||||
project: "Linked project pill with its color.",
|
||||
workspace: "Execution or project workspace used for the issue.",
|
||||
parent: "Parent issue identifier and title.",
|
||||
labels: "Issue labels and tags.",
|
||||
updated: "Latest visible activity time.",
|
||||
};
|
||||
|
||||
export function issueActivityText(issue: Issue): string {
|
||||
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
|
||||
}
|
||||
|
||||
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
||||
return columns
|
||||
.map((column) => {
|
||||
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||
if (column === "parent") return "minmax(5rem, 7rem)";
|
||||
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||
return "minmax(4rem, 5.5rem)";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function IssueColumnPicker({
|
||||
availableColumns,
|
||||
visibleColumnSet,
|
||||
onToggleColumn,
|
||||
onResetColumns,
|
||||
title,
|
||||
}: {
|
||||
availableColumns: InboxIssueColumn[];
|
||||
visibleColumnSet: ReadonlySet<InboxIssueColumn>;
|
||||
onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
|
||||
onResetColumns: () => void;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden h-8 shrink-0 px-2 text-xs sm:inline-flex"
|
||||
>
|
||||
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
||||
Columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Desktop issue rows
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{availableColumns.map((column) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column}
|
||||
checked={visibleColumnSet.has(column)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(checked) => onToggleColumn(column, checked === true)}
|
||||
className="items-start rounded-lg px-3 py-2.5 pl-8"
|
||||
>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{issueColumnLabels[column]}
|
||||
</span>
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
{issueColumnDescriptions[column]}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={onResetColumns}
|
||||
className="rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
Reset defaults
|
||||
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxIssueMetaLeading({
|
||||
issue,
|
||||
isLive,
|
||||
showStatus = true,
|
||||
showIdentifier = true,
|
||||
statusSlot,
|
||||
}: {
|
||||
issue: Issue;
|
||||
isLive: boolean;
|
||||
showStatus?: boolean;
|
||||
showIdentifier?: boolean;
|
||||
statusSlot?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{showStatus ? (
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
{statusSlot ?? <StatusIcon status={issue.status} />}
|
||||
</span>
|
||||
) : null}
|
||||
{showIdentifier ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
) : null}
|
||||
{isLive && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
||||
"bg-blue-500/10",
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex h-2 w-2 rounded-full",
|
||||
"bg-blue-500",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"hidden text-[11px] font-medium sm:inline",
|
||||
"text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxIssueTrailingColumns({
|
||||
issue,
|
||||
columns,
|
||||
projectName,
|
||||
projectColor,
|
||||
workspaceName,
|
||||
assigneeName,
|
||||
currentUserId,
|
||||
parentIdentifier,
|
||||
parentTitle,
|
||||
assigneeContent,
|
||||
}: {
|
||||
issue: Issue;
|
||||
columns: InboxIssueColumn[];
|
||||
projectName: string | null;
|
||||
projectColor: string | null;
|
||||
workspaceName: string | null;
|
||||
assigneeName: string | null;
|
||||
currentUserId: string | null;
|
||||
parentIdentifier: string | null;
|
||||
parentTitle: string | null;
|
||||
assigneeContent?: ReactNode;
|
||||
}) {
|
||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="grid items-center gap-2"
|
||||
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
if (column === "assignee") {
|
||||
if (assigneeContent) {
|
||||
return <span key={column} className="min-w-0">{assigneeContent}</span>;
|
||||
}
|
||||
|
||||
if (issue.assigneeAgentId) {
|
||||
return (
|
||||
<span key={column} className="min-w-0 text-xs text-foreground">
|
||||
<Identity
|
||||
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
|
||||
size="sm"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (issue.assigneeUserId) {
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||
{userLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
Unassigned
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (column === "project") {
|
||||
if (projectName) {
|
||||
const accentColor = projectColor ?? "#64748b";
|
||||
return (
|
||||
<span
|
||||
key={column}
|
||||
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
|
||||
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
<span className="truncate">{projectName}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
No project
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (column === "labels") {
|
||||
if ((issue.labels ?? []).length > 0) {
|
||||
return (
|
||||
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
|
||||
{(issue.labels ?? []).slice(0, 2).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex min-w-0 max-w-full items-center font-medium"
|
||||
style={{
|
||||
color: pickTextColorForPillBg(label.color, 0.12),
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{label.name}</span>
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 2 ? (
|
||||
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
|
||||
+{(issue.labels ?? []).length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
if (column === "workspace") {
|
||||
if (!workspaceName) {
|
||||
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{workspaceName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (column === "parent") {
|
||||
if (!issue.parentId) {
|
||||
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
|
||||
{parentIdentifier ? (
|
||||
<span className="font-mono">{parentIdentifier}</span>
|
||||
) : (
|
||||
<span className="italic">Sub-issue</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (column === "updated") {
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
||||
{activityText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { act } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -143,6 +144,30 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
};
|
||||
}
|
||||
|
||||
function createExecutionPolicy(overrides: Partial<IssueExecutionPolicy> = {}): IssueExecutionPolicy {
|
||||
return {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createExecutionState(overrides: Partial<IssueExecutionState> = {}): IssueExecutionState {
|
||||
return {
|
||||
status: "changes_requested",
|
||||
currentStageId: "stage-1",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "agent-1", userId: null },
|
||||
returnAssignee: { type: "agent", agentId: "agent-2", userId: null },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -201,4 +226,119 @@ describe("IssueProperties", () => {
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
executionPolicy: createExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "review-stage",
|
||||
type: "review",
|
||||
approvalsNeeded: 1,
|
||||
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const runReviewButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Run review now"));
|
||||
expect(runReviewButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
runReviewButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ status: "in_review" });
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a run approval action when approval is the next runnable stage", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
executionPolicy: createExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "approval-stage",
|
||||
type: "approval",
|
||||
approvalsNeeded: 1,
|
||||
participants: [{ id: "participant-2", type: "user", agentId: null, userId: "user-1" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Run approval now");
|
||||
expect(container.textContent).not.toContain("Run review now");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the run review action available after changes are requested", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
status: "in_progress",
|
||||
executionPolicy: createExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "review-stage",
|
||||
type: "review",
|
||||
approvalsNeeded: 1,
|
||||
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
executionState: createExecutionState(),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Run review now");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("hides the run action while an execution stage is already pending", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
status: "in_review",
|
||||
executionPolicy: createExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "review-stage",
|
||||
type: "review",
|
||||
approvalsNeeded: 1,
|
||||
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
executionState: createExecutionState({
|
||||
status: "pending",
|
||||
currentStageType: "review",
|
||||
lastDecisionOutcome: null,
|
||||
}),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).not.toContain("Run review now");
|
||||
expect(container.textContent).not.toContain("Run approval now");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,6 +309,26 @@ export function IssueProperties({
|
||||
const approverTrigger = approverValues.length > 0
|
||||
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||
: <span className="text-sm text-muted-foreground">None</span>;
|
||||
const nextRunnableExecutionStage = (() => {
|
||||
if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) {
|
||||
return issue.executionState.currentStageType;
|
||||
}
|
||||
if (issue.executionState) return null;
|
||||
if (reviewerValues.length > 0) return "review";
|
||||
if (approverValues.length > 0) return "approval";
|
||||
return null;
|
||||
})();
|
||||
const runExecutionButton = (stageType: "review" | "approval") => (
|
||||
<PropertyRow label="">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
onClick={() => onUpdate({ status: "in_review" })}
|
||||
>
|
||||
{stageType === "review" ? "Run review now" : "Run approval now"}
|
||||
</button>
|
||||
</PropertyRow>
|
||||
);
|
||||
const currentExecutionLabel = (() => {
|
||||
if (!issue.executionState?.currentStageType) return null;
|
||||
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
|
||||
@@ -846,15 +866,13 @@ export function IssueProperties({
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
)}
|
||||
) : null}
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Sub-issues">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{childIssues.length > 0 ? (
|
||||
childIssues.map((child) => (
|
||||
{childIssues.length > 0
|
||||
? childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
@@ -863,9 +881,7 @@ export function IssueProperties({
|
||||
{child.identifier ?? child.title}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
)}
|
||||
: null}
|
||||
{onAddSubIssue ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -896,6 +912,7 @@ export function IssueProperties({
|
||||
() => updateExecutionPolicy([], approverValues),
|
||||
)}
|
||||
</PropertyPicker>
|
||||
{nextRunnableExecutionStage === "review" && reviewerValues.length > 0 ? runExecutionButton("review") : null}
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
@@ -914,6 +931,7 @@ export function IssueProperties({
|
||||
() => updateExecutionPolicy(reviewerValues, []),
|
||||
)}
|
||||
</PropertyPicker>
|
||||
{nextRunnableExecutionStage === "approval" && approverValues.length > 0 ? runExecutionButton("approval") : null}
|
||||
|
||||
{currentExecutionLabel && (
|
||||
<PropertyRow label="Execution">
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, Project } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/execution-workspaces", () => ({
|
||||
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Issue workspace",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
identifier: "PAP-1",
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: "shared_workspace",
|
||||
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createProject(): Project {
|
||||
return {
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
urlKey: "project-1",
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: "Project 1",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#22c55e",
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
archivedAt: null,
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
},
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/project-1",
|
||||
effectiveLocalFolder: "/tmp/project-1",
|
||||
origin: "managed_checkout",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
createdAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function renderCard(container: HTMLDivElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueWorkspaceCard issue={createIssue()} project={createProject()} onUpdate={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("IssueWorkspaceCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockExecutionWorkspacesApi.list.mockReset();
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders a stable skeleton while workspace settings are still loading", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const root = renderCard(container);
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('[data-testid="issue-workspace-card-skeleton"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -156,6 +157,25 @@ function statusBadge(status: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function IssueWorkspaceCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3 space-y-3" data-testid="issue-workspace-card-skeleton">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-14" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Main component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -195,14 +215,15 @@ export function IssueWorkspaceCard({
|
||||
const companyId = issue.companyId ?? selectedCompanyId;
|
||||
const [editing, setEditing] = useState(initialEditing);
|
||||
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
const { data: experimentalSettings, isLoading: experimentalSettingsLoading } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const projectWorkspacePolicyEnabled = Boolean(project?.executionWorkspacePolicy?.enabled);
|
||||
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
|
||||
&& Boolean(project?.executionWorkspacePolicy?.enabled);
|
||||
&& projectWorkspacePolicyEnabled;
|
||||
|
||||
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
|
||||
|
||||
@@ -314,6 +335,10 @@ export function IssueWorkspaceCard({
|
||||
setEditing(false);
|
||||
}, [currentSelection, issue.executionWorkspaceId]);
|
||||
|
||||
if (project && projectWorkspacePolicyEnabled && experimentalSettingsLoading) {
|
||||
return <IssueWorkspaceCardSkeleton />;
|
||||
}
|
||||
|
||||
if (!policyEnabled || !project) return null;
|
||||
|
||||
const showEditingControls = livePreview || editing;
|
||||
|
||||
@@ -25,6 +25,14 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => companyState,
|
||||
}));
|
||||
@@ -41,8 +49,30 @@ vi.mock("../api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/execution-workspaces", () => ({
|
||||
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./IssueRow", () => ({
|
||||
IssueRow: ({ issue }: { issue: Issue }) => <div data-testid="issue-row">{issue.title}</div>,
|
||||
IssueRow: ({
|
||||
issue,
|
||||
desktopMetaLeading,
|
||||
desktopTrailing,
|
||||
}: {
|
||||
issue: Issue;
|
||||
desktopMetaLeading?: ReactNode;
|
||||
desktopTrailing?: ReactNode;
|
||||
}) => (
|
||||
<div data-testid="issue-row">
|
||||
<span>{issue.title}</span>
|
||||
{desktopMetaLeading}
|
||||
{desktopTrailing}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./KanbanBoard", () => ({
|
||||
@@ -90,6 +120,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
labelIds: [],
|
||||
myLastTouchAt: null,
|
||||
lastExternalCommentAt: null,
|
||||
lastActivityAt: null,
|
||||
isUnreadForMe: false,
|
||||
...overrides,
|
||||
};
|
||||
@@ -148,11 +179,18 @@ describe("IssuesList", () => {
|
||||
mockIssuesApi.list.mockReset();
|
||||
mockIssuesApi.listLabels.mockReset();
|
||||
mockAuthApi.getSession.mockReset();
|
||||
mockExecutionWorkspacesApi.list.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
@@ -184,4 +222,89 @@ describe("IssuesList", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("debounces search updates so typing does not notify the page on every keystroke", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onSearchChange = vi.fn();
|
||||
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[localIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onSearchChange={onSearchChange}
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
expect(valueSetter).toBeTypeOf("function");
|
||||
|
||||
act(() => {
|
||||
if (!input || !valueSetter) return;
|
||||
valueSetter.call(input, "a");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
valueSetter.call(input, "ab");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSearchChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(149);
|
||||
});
|
||||
|
||||
expect(onSearchChange).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onSearchChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSearchChange).toHaveBeenCalledWith("ab");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses the inbox issue column controls and persisted column visibility", async () => {
|
||||
localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "assignee"]));
|
||||
|
||||
const assignedIssue = createIssue({
|
||||
id: "issue-assigned",
|
||||
identifier: "PAP-9",
|
||||
title: "Assigned issue",
|
||||
assigneeAgentId: "agent-1",
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[assignedIssue]}
|
||||
agents={[{ id: "agent-1", name: "Agent One" }]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Columns");
|
||||
expect(container.textContent).toContain("PAP-9");
|
||||
expect(container.textContent).toContain("Agent One");
|
||||
expect(container.textContent).not.toContain("Updated");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+348
-154
@@ -1,15 +1,31 @@
|
||||
import { useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { formatDate, cn } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
getAvailableInboxIssueColumns,
|
||||
loadInboxIssueColumns,
|
||||
normalizeInboxIssueColumns,
|
||||
resolveIssueWorkspaceName,
|
||||
saveInboxIssueColumns,
|
||||
type InboxIssueColumn,
|
||||
} from "../lib/inbox";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
InboxIssueMetaLeading,
|
||||
InboxIssueTrailingColumns,
|
||||
IssueColumnPicker,
|
||||
issueActivityText,
|
||||
issueTrailingColumns,
|
||||
} from "./IssueColumns";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
@@ -24,12 +40,13 @@ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/component
|
||||
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { Issue, Project } from "@paperclipai/shared";
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||
const priorityOrder = ["critical", "high", "medium", "low"];
|
||||
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
@@ -45,7 +62,7 @@ export type IssueViewState = {
|
||||
projects: string[];
|
||||
sortField: "status" | "priority" | "title" | "created" | "updated";
|
||||
sortDir: "asc" | "desc";
|
||||
groupBy: "status" | "priority" | "assignee" | "none";
|
||||
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||
viewMode: "list" | "board";
|
||||
collapsedGroups: string[];
|
||||
collapsedParents: string[];
|
||||
@@ -152,10 +169,7 @@ interface Agent {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
|
||||
|
||||
interface IssuesListProps {
|
||||
issues: Issue[];
|
||||
@@ -176,6 +190,50 @@ interface IssuesListProps {
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function IssueSearchInput({
|
||||
value,
|
||||
onDebouncedChange,
|
||||
}: {
|
||||
value: string;
|
||||
onDebouncedChange?: (search: string) => void;
|
||||
}) {
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
const lastCommittedValueRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValue(value);
|
||||
lastCommittedValueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onDebouncedChange || draftValue === lastCommittedValueRef.current) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
lastCommittedValueRef.current = draftValue;
|
||||
startTransition(() => {
|
||||
onDebouncedChange(draftValue);
|
||||
});
|
||||
}, ISSUE_SEARCH_DEBOUNCE_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [draftValue, onDebouncedChange]);
|
||||
|
||||
return (
|
||||
<div className="relative w-48 sm:w-64 md:w-80">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={draftValue}
|
||||
onChange={(e) => {
|
||||
setDraftValue(e.target.value);
|
||||
}}
|
||||
placeholder="Search issues..."
|
||||
className="pl-7 text-xs sm:text-sm"
|
||||
aria-label="Search issues"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssuesList({
|
||||
issues,
|
||||
isLoading,
|
||||
@@ -198,7 +256,13 @@ export function IssuesList({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
|
||||
// Scope the storage key per company so folding/view state is independent across companies.
|
||||
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
||||
@@ -212,6 +276,7 @@ export function IssuesList({
|
||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||
|
||||
@@ -259,12 +324,103 @@ export function IssuesList({
|
||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
const { data: executionWorkspaces = [] } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.executionWorkspaces.list(selectedCompanyId)
|
||||
: ["execution-workspaces", "__disabled__"],
|
||||
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
|
||||
});
|
||||
|
||||
const agentName = useCallback((id: string | null) => {
|
||||
if (!id || !agents) return null;
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
}, [agents]);
|
||||
|
||||
const projectById = useMemo(() => {
|
||||
const map = new Map<string, { name: string; color: string | null }>();
|
||||
for (const project of projects ?? []) {
|
||||
map.set(project.id, { name: project.name, color: project.color ?? null });
|
||||
}
|
||||
return map;
|
||||
}, [projects]);
|
||||
|
||||
const projectWorkspaceById = useMemo(() => {
|
||||
const map = new Map<string, { name: string }>();
|
||||
for (const project of projects ?? []) {
|
||||
for (const workspace of project.workspaces ?? []) {
|
||||
map.set(workspace.id, { name: workspace.name || project.name });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [projects]);
|
||||
|
||||
const defaultProjectWorkspaceIdByProjectId = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const project of projects ?? []) {
|
||||
const defaultWorkspaceId =
|
||||
project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||
?? project.primaryWorkspace?.id
|
||||
?? null;
|
||||
if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId);
|
||||
}
|
||||
return map;
|
||||
}, [projects]);
|
||||
|
||||
const executionWorkspaceById = useMemo(() => {
|
||||
const map = new Map<string, {
|
||||
name: string;
|
||||
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}>();
|
||||
for (const workspace of executionWorkspaces) {
|
||||
map.set(workspace.id, {
|
||||
name: workspace.name,
|
||||
mode: workspace.mode,
|
||||
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [executionWorkspaces]);
|
||||
|
||||
const workspaceNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const [workspaceId, workspace] of projectWorkspaceById) {
|
||||
map.set(workspaceId, workspace.name);
|
||||
}
|
||||
for (const [workspaceId, workspace] of executionWorkspaceById) {
|
||||
map.set(workspaceId, workspace.name);
|
||||
}
|
||||
return map;
|
||||
}, [executionWorkspaceById, projectWorkspaceById]);
|
||||
|
||||
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
|
||||
const availableIssueColumns = useMemo(
|
||||
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
|
||||
[isolatedWorkspacesEnabled],
|
||||
);
|
||||
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
|
||||
const visibleTrailingIssueColumns = useMemo(
|
||||
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
||||
[availableIssueColumnSet, visibleIssueColumnSet],
|
||||
);
|
||||
|
||||
const issueById = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues) {
|
||||
map.set(issue.id, issue);
|
||||
}
|
||||
return map;
|
||||
}, [issues]);
|
||||
|
||||
const issueTitleMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const issue of issues) {
|
||||
map.set(issue.id, issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title);
|
||||
}
|
||||
return map;
|
||||
}, [issues]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||
@@ -295,6 +451,36 @@ export function IssuesList({
|
||||
.filter((p) => groups[p]?.length)
|
||||
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
||||
}
|
||||
if (viewState.groupBy === "workspace") {
|
||||
const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace");
|
||||
return Object.keys(groups)
|
||||
.sort((a, b) => {
|
||||
// Groups with items first, "no workspace" last
|
||||
if (a === "__no_workspace") return 1;
|
||||
if (b === "__no_workspace") return -1;
|
||||
return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0);
|
||||
})
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: key === "__no_workspace" ? "No Workspace" : (workspaceNameMap.get(key) ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}
|
||||
if (viewState.groupBy === "parent") {
|
||||
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
|
||||
return Object.keys(groups)
|
||||
.sort((a, b) => {
|
||||
// Groups with items first, "no parent" last
|
||||
if (a === "__no_parent") return 1;
|
||||
if (b === "__no_parent") return -1;
|
||||
return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0);
|
||||
})
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: key === "__no_parent" ? "No Parent" : (issueTitleMap.get(key) ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}
|
||||
// assignee
|
||||
const groups = groupBy(
|
||||
filtered,
|
||||
@@ -310,7 +496,7 @@ export function IssuesList({
|
||||
: (agentName(key) ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||
|
||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||
const defaults: Record<string, string> = {};
|
||||
@@ -322,10 +508,27 @@ export function IssuesList({
|
||||
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
||||
else defaults.assigneeAgentId = groupKey;
|
||||
}
|
||||
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
||||
defaults.parentId = groupKey;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}, [projectId, viewState.groupBy]);
|
||||
|
||||
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
|
||||
const normalized = normalizeInboxIssueColumns(next);
|
||||
setVisibleIssueColumns(normalized);
|
||||
saveInboxIssueColumns(normalized);
|
||||
}, []);
|
||||
|
||||
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
setIssueColumns([...visibleIssueColumns, column]);
|
||||
return;
|
||||
}
|
||||
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
||||
}, [setIssueColumns, visibleIssueColumns]);
|
||||
|
||||
const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
||||
setAssigneePickerIssueId(null);
|
||||
@@ -342,19 +545,13 @@ export function IssuesList({
|
||||
<Plus className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">New Issue</span>
|
||||
</Button>
|
||||
<div className="relative w-48 sm:w-64 md:w-80">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={issueSearch}
|
||||
onChange={(e) => {
|
||||
setIssueSearch(e.target.value);
|
||||
onSearchChange?.(e.target.value);
|
||||
}}
|
||||
placeholder="Search issues..."
|
||||
className="pl-7 text-xs sm:text-sm"
|
||||
aria-label="Search issues"
|
||||
/>
|
||||
</div>
|
||||
<IssueSearchInput
|
||||
value={issueSearch}
|
||||
onDebouncedChange={(nextSearch) => {
|
||||
setIssueSearch(nextSearch);
|
||||
onSearchChange?.(nextSearch);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
||||
@@ -376,6 +573,14 @@ export function IssuesList({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<IssueColumnPicker
|
||||
availableColumns={availableIssueColumns}
|
||||
visibleColumnSet={visibleIssueColumnSet}
|
||||
onToggleColumn={toggleIssueColumn}
|
||||
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||
title="Choose which issue columns stay visible"
|
||||
/>
|
||||
|
||||
{/* Filter */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -605,6 +810,8 @@ export function IssuesList({
|
||||
["status", "Status"],
|
||||
["priority", "Priority"],
|
||||
["assignee", "Assignee"],
|
||||
["workspace", "Workspace"],
|
||||
["parent", "Parent Issue"],
|
||||
["none", "None"],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
@@ -684,6 +891,8 @@ export function IssuesList({
|
||||
const hasChildren = children.length > 0;
|
||||
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
|
||||
const isExpanded = !viewState.collapsedParents.includes(issue.id);
|
||||
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
|
||||
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -728,154 +937,139 @@ export function IssuesList({
|
||||
) : (
|
||||
<span className="hidden w-3.5 shrink-0 sm:block" />
|
||||
)}
|
||||
<span
|
||||
className="hidden shrink-0 sm:inline-flex"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{liveIssueIds?.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||
<InboxIssueMetaLeading
|
||||
issue={issue}
|
||||
isLive={liveIssueIds?.has(issue.id) === true}
|
||||
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||
statusSlot={(
|
||||
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
mobileMeta={timeAgo(issue.updatedAt)}
|
||||
mobileMeta={issueActivityText(issue).toLowerCase()}
|
||||
desktopTrailing={(
|
||||
<>
|
||||
{(issue.labels ?? []).length > 0 && (
|
||||
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
|
||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
color: pickTextColorForPillBg(label.color, 0.12),
|
||||
backgroundColor: `${label.color}1f`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{(issue.labels ?? []).length - 3}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<Popover
|
||||
open={assigneePickerIssueId === issue.id}
|
||||
onOpenChange={(open) => {
|
||||
setAssigneePickerIssueId(open ? issue.id : null);
|
||||
if (!open) setAssigneeSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
visibleTrailingIssueColumns.length > 0 ? (
|
||||
<InboxIssueTrailingColumns
|
||||
issue={issue}
|
||||
columns={visibleTrailingIssueColumns}
|
||||
projectName={issueProject?.name ?? null}
|
||||
projectColor={issueProject?.color ?? null}
|
||||
workspaceName={resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
})}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
currentUserId={currentUserId}
|
||||
parentIdentifier={parentIssue?.identifier ?? null}
|
||||
parentTitle={parentIssue?.title ?? null}
|
||||
assigneeContent={(
|
||||
<Popover
|
||||
open={assigneePickerIssueId === issue.id}
|
||||
onOpenChange={(open) => {
|
||||
setAssigneePickerIssueId(open ? issue.id : null);
|
||||
if (!open) setAssigneeSearch("");
|
||||
}}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-1"
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||
>
|
||||
<input
|
||||
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Search assignees..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null, null);
|
||||
}}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{currentUserId && (
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null, currentUserId);
|
||||
}}
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>Me</span>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{(agents ?? [])
|
||||
.filter((agent) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
||||
})
|
||||
.map((agent) => (
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-1"
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||
>
|
||||
<input
|
||||
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Search assignees..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeAgentId === agent.id && "bg-accent",
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, agent.id, null);
|
||||
assignIssue(issue.id, null, null);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
No assignee
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null, currentUserId);
|
||||
}}
|
||||
>
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>Me</span>
|
||||
</button>
|
||||
)}
|
||||
{(agents ?? [])
|
||||
.filter((agent) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
||||
})
|
||||
.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeAgentId === agent.id && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, agent.id, null);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
/>
|
||||
) : undefined
|
||||
)}
|
||||
trailingMeta={formatDate(issue.createdAt)}
|
||||
/>
|
||||
{hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
interface ShortcutEntry {
|
||||
keys: string[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ShortcutSection {
|
||||
title: string;
|
||||
shortcuts: ShortcutEntry[];
|
||||
}
|
||||
|
||||
const sections: ShortcutSection[] = [
|
||||
{
|
||||
title: "Inbox",
|
||||
shortcuts: [
|
||||
{ keys: ["j"], label: "Move down" },
|
||||
{ keys: ["k"], label: "Move up" },
|
||||
{ keys: ["Enter"], label: "Open selected item" },
|
||||
{ keys: ["a"], label: "Archive item" },
|
||||
{ keys: ["y"], label: "Archive item" },
|
||||
{ keys: ["r"], label: "Mark as read" },
|
||||
{ keys: ["U"], label: "Mark as unread" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Issue detail",
|
||||
shortcuts: [
|
||||
{ keys: ["y"], label: "Quick-archive back to inbox" },
|
||||
{ keys: ["g", "i"], label: "Go to inbox" },
|
||||
{ keys: ["g", "c"], label: "Focus comment composer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Global",
|
||||
shortcuts: [
|
||||
{ keys: ["c"], label: "New issue" },
|
||||
{ keys: ["["], label: "Toggle sidebar" },
|
||||
{ keys: ["]"], label: "Toggle panel" },
|
||||
{ keys: ["?"], label: "Show keyboard shortcuts" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function KeyCap({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs font-medium text-foreground shadow-[0_1px_0_1px_hsl(var(--border))]">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsCheatsheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden" showCloseButton={false}>
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="text-base">Keyboard shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="px-5 py-3">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{section.shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.label + shortcut.keys.join()}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<span className="text-sm text-foreground/90">{shortcut.label}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, i) => (
|
||||
<span key={key} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-xs text-muted-foreground">then</span>}
|
||||
<KeyCap>{key}</KeyCap>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press <KeyCap>Esc</KeyCap> to close · Shortcuts are disabled in text fields
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { NewIssueDialog } from "./NewIssueDialog";
|
||||
import { NewProjectDialog } from "./NewProjectDialog";
|
||||
import { NewGoalDialog } from "./NewGoalDialog";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
normalizeRememberedInstanceSettingsPath,
|
||||
} from "../lib/instance-settings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||
import { cn } from "../lib/utils";
|
||||
import { NotFoundPage } from "../pages/NotFound";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -69,6 +71,7 @@ export function Layout() {
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -151,6 +154,7 @@ export function Layout() {
|
||||
onNewIssue: () => openNewIssue(),
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onTogglePanel: togglePanel,
|
||||
onShowShortcuts: () => setShortcutsOpen(true),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -265,6 +269,12 @@ export function Layout() {
|
||||
}
|
||||
}, [location.hash, location.pathname, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const mainContent = document.getElementById("main-content");
|
||||
return scheduleMainContentFocus(mainContent);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
||||
<div
|
||||
@@ -420,7 +430,7 @@ export function Layout() {
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"flex-1 p-4 md:p-6",
|
||||
"flex-1 p-4 outline-none md:p-6",
|
||||
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
||||
)}
|
||||
>
|
||||
@@ -443,6 +453,7 @@ export function Layout() {
|
||||
<NewProjectDialog />
|
||||
<NewGoalDialog />
|
||||
<NewAgentDialog />
|
||||
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
||||
<ToastViewport />
|
||||
</div>
|
||||
</GeneralSettingsProvider>
|
||||
|
||||
@@ -11,6 +11,8 @@ interface MarkdownBodyProps {
|
||||
style?: React.CSSProperties;
|
||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||
resolveImageSrc?: (src: string) => string | null;
|
||||
/** Called when a user clicks an inline image */
|
||||
onImageClick?: (src: string) => void;
|
||||
}
|
||||
|
||||
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
||||
@@ -92,7 +94,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownBody({ children, className, style, resolveImageSrc }: MarkdownBodyProps) {
|
||||
export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
const components: Components = {
|
||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||
@@ -132,10 +134,19 @@ export function MarkdownBody({ children, className, style, resolveImageSrc }: Ma
|
||||
);
|
||||
},
|
||||
};
|
||||
if (resolveImageSrc) {
|
||||
if (resolveImageSrc || onImageClick) {
|
||||
components.img = ({ node: _node, src, alt, ...imgProps }) => {
|
||||
const resolved = src ? resolveImageSrc(src) : null;
|
||||
return <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
|
||||
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
|
||||
const finalSrc = resolved ?? src;
|
||||
return (
|
||||
<img
|
||||
{...imgProps}
|
||||
src={finalSrc}
|
||||
alt={alt ?? ""}
|
||||
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
|
||||
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -364,6 +364,19 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
return map;
|
||||
}, [mentions]);
|
||||
|
||||
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
||||
ref.current = instance;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
if (valueRef.current !== latestValueRef.current) {
|
||||
// Re-apply the latest controlled value once MDXEditor exposes its imperative API.
|
||||
echoIgnoreMarkdownRef.current = valueRef.current;
|
||||
instance.setMarkdown(valueRef.current);
|
||||
latestValueRef.current = valueRef.current;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredMentions = useMemo<AutocompleteOption[]>(() => {
|
||||
if (!mentionState) return [];
|
||||
const q = mentionState.query.trim().toLowerCase();
|
||||
@@ -379,16 +392,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
||||
}, [mentionState, mentions, slashCommands]);
|
||||
|
||||
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
||||
ref.current = instance;
|
||||
if (instance) {
|
||||
const v = valueRef.current;
|
||||
echoIgnoreMarkdownRef.current = v;
|
||||
instance.setMarkdown(v);
|
||||
latestValueRef.current = v;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => {
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
buildOnboardingProjectPayload,
|
||||
selectDefaultCompanyGoalId
|
||||
} from "../lib/onboarding-launch";
|
||||
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL
|
||||
@@ -460,15 +461,7 @@ export function OnboardingWizard() {
|
||||
role: "ceo",
|
||||
adapterType,
|
||||
adapterConfig: buildAdapterConfig(),
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalSec: 3600,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 10,
|
||||
maxConcurrentRuns: 1
|
||||
}
|
||||
}
|
||||
runtimeConfig: buildNewAgentRuntimeConfig()
|
||||
});
|
||||
setCreatedAgentId(agent.id);
|
||||
queryClient.invalidateQueries({
|
||||
|
||||
@@ -36,18 +36,20 @@ function updateVariableList(
|
||||
}
|
||||
|
||||
export function RoutineVariablesEditor({
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
value: RoutineVariable[];
|
||||
onChange: (value: RoutineVariable[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const syncedVariables = useMemo(
|
||||
() => syncRoutineVariablesWithTemplate(description, value),
|
||||
[description, value],
|
||||
() => syncRoutineVariablesWithTemplate([title, description], value),
|
||||
[description, title, value],
|
||||
);
|
||||
const syncedSignature = serializeVariables(syncedVariables);
|
||||
const currentSignature = serializeVariables(value);
|
||||
@@ -68,7 +70,7 @@ export function RoutineVariablesEditor({
|
||||
<div>
|
||||
<p className="text-sm font-medium">Variables</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Detected from `{"{{name}}"}` placeholders in the routine instructions.
|
||||
Detected from `{"{{name}}"}` placeholders in the routine title and instructions.
|
||||
</p>
|
||||
</div>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { getWorktreeUiBranding } from "../lib/worktree-branding";
|
||||
|
||||
export function WorktreeBanner() {
|
||||
const branding = getWorktreeUiBranding();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyName = useCallback(() => {
|
||||
if (!branding) return;
|
||||
navigator.clipboard.writeText(branding.name).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}, [branding]);
|
||||
|
||||
if (!branding) return null;
|
||||
|
||||
return (
|
||||
@@ -18,7 +29,14 @@ export function WorktreeBanner() {
|
||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<span className="shrink-0 opacity-70">Worktree</span>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" aria-hidden="true" />
|
||||
<span className="truncate font-semibold tracking-[0.12em]">{branding.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyName}
|
||||
title="Click to copy worktree name"
|
||||
className="truncate font-semibold tracking-[0.12em] cursor-pointer hover:opacity-80 transition-opacity bg-transparent border-none p-0 text-current uppercase text-[11px]"
|
||||
>
|
||||
{copied ? "Copied!" : branding.name}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
|
||||
|
||||
const { useQueryMock, logMock } = vi.hoisted(() => ({
|
||||
useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
|
||||
logMock: vi.fn(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: useQueryMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: {
|
||||
getGeneral: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../api/heartbeats", () => ({
|
||||
heartbeatsApi: {
|
||||
log: logMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters", () => ({
|
||||
buildTranscript: (chunks: unknown[]) => chunks,
|
||||
getUIAdapter: () => null,
|
||||
onAdapterChange: () => () => {},
|
||||
}));
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
closeCalls: Array<{ code?: number; reason?: string }> = [];
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
this.closeCalls.push({ code, reason });
|
||||
this.readyState = FakeWebSocket.CLOSING;
|
||||
}
|
||||
|
||||
triggerOpen() {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.onopen?.(new Event("open"));
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("useLiveRunTranscripts", () => {
|
||||
const OriginalWebSocket = globalThis.WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.instances = [];
|
||||
useQueryMock.mockClear();
|
||||
logMock.mockClear();
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = OriginalWebSocket;
|
||||
});
|
||||
|
||||
it("waits for a connecting socket to open before closing it during cleanup", async () => {
|
||||
function Harness() {
|
||||
useLiveRunTranscripts({
|
||||
companyId: "company-1",
|
||||
runs: [{ id: "run-1", status: "running", adapterType: "codex_local" }],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
expect(socket.closeCalls).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
expect(socket.closeCalls).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
socket.triggerOpen();
|
||||
});
|
||||
|
||||
expect(socket.closeCalls).toEqual([{ code: 1000, reason: "live_run_transcripts_unmount" }]);
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -281,7 +281,16 @@ export function useLiveRunTranscripts({
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.onclose = null;
|
||||
socket.close(1000, "live_run_transcripts_unmount");
|
||||
if (socket.readyState === WebSocket.CONNECTING) {
|
||||
// Defer the close until the handshake completes so the browser
|
||||
// does not emit a noisy "closed before the connection is established"
|
||||
// warning during rapid run teardown.
|
||||
socket.onopen = () => {
|
||||
socket?.close(1000, "live_run_transcripts_unmount");
|
||||
};
|
||||
} else if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.close(1000, "live_run_transcripts_unmount");
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [activeRunIds, companyId, runById]);
|
||||
|
||||
@@ -38,20 +38,24 @@ const buttonVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
}, ref) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
@@ -59,6 +63,8 @@ function Button({
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -5,7 +5,7 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
it("refreshes touched inbox queries for issue activity", () => {
|
||||
it("refreshes touched inbox queries and only the changed issue data for issue updates", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
@@ -20,6 +20,7 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.updated",
|
||||
details: null,
|
||||
},
|
||||
);
|
||||
@@ -33,6 +34,58 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.detail("issue-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.activity("issue-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.comments("issue-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.runs("issue-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.documents("issue-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.attachments("issue-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.approvals("issue-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.liveRuns("issue-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.activeRun("issue-1"),
|
||||
});
|
||||
});
|
||||
|
||||
it("still refreshes comments when a comment activity event arrives", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.comment_added",
|
||||
details: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.comments("issue-1"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -487,6 +487,7 @@ function invalidateActivityQueries(
|
||||
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
const action = readString(payload.action);
|
||||
|
||||
if (entityType === "issue") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
@@ -498,14 +499,10 @@ function invalidateActivityQueries(
|
||||
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||
for (const ref of issueRefs) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
|
||||
if (action === "issue.comment_added") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { ApiError } from "../api/client";
|
||||
import { inboxDismissalsApi } from "../api/inboxDismissals";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
buildInboxDismissedAtByKey,
|
||||
computeInboxBadgeData,
|
||||
getRecentTouchedIssues,
|
||||
loadDismissedInboxItems,
|
||||
saveDismissedInboxItems,
|
||||
loadDismissedInboxAlerts,
|
||||
saveDismissedInboxAlerts,
|
||||
loadReadInboxItems,
|
||||
saveReadInboxItems,
|
||||
READ_ITEMS_KEY,
|
||||
@@ -19,13 +21,13 @@ import {
|
||||
|
||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||
|
||||
export function useDismissedInboxItems() {
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
|
||||
export function useDismissedInboxAlerts() {
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== "paperclip:inbox:dismissed") return;
|
||||
setDismissed(loadDismissedInboxItems());
|
||||
setDismissed(loadDismissedInboxAlerts());
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
@@ -35,7 +37,7 @@ export function useDismissedInboxItems() {
|
||||
setDismissed((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
saveDismissedInboxItems(next);
|
||||
saveDismissedInboxAlerts(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -43,6 +45,63 @@ export function useDismissedInboxItems() {
|
||||
return { dismissed, dismiss };
|
||||
}
|
||||
|
||||
export function useInboxDismissals(companyId: string | null | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = companyId
|
||||
? queryKeys.inboxDismissals(companyId)
|
||||
: ["inbox-dismissals", "__disabled__"] as const;
|
||||
|
||||
const { data: dismissals = [] } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => inboxDismissalsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.dismiss(companyId!, itemKey),
|
||||
onMutate: async ({ itemKey }) => {
|
||||
if (!companyId) return { previous: [] as typeof dismissals };
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previous = queryClient.getQueryData<typeof dismissals>(queryKey) ?? [];
|
||||
const now = new Date();
|
||||
queryClient.setQueryData(queryKey, [
|
||||
{
|
||||
id: `optimistic:${itemKey}`,
|
||||
companyId,
|
||||
userId: "me",
|
||||
itemKey,
|
||||
dismissedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
...previous.filter((dismissal) => dismissal.itemKey !== itemKey),
|
||||
]);
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (!context) return;
|
||||
queryClient.setQueryData(queryKey, context.previous);
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!companyId) return;
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
},
|
||||
});
|
||||
|
||||
const dismissedAtByKey = useMemo(
|
||||
() => buildInboxDismissedAtByKey(dismissals),
|
||||
[dismissals],
|
||||
);
|
||||
|
||||
return {
|
||||
dismissals,
|
||||
dismissedAtByKey,
|
||||
dismiss: (itemKey: string) => dismissMutation.mutate({ itemKey }),
|
||||
isPending: dismissMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export function useReadInboxItems() {
|
||||
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
|
||||
|
||||
@@ -77,7 +136,8 @@ export function useReadInboxItems() {
|
||||
}
|
||||
|
||||
export function useInboxBadge(companyId: string | null | undefined) {
|
||||
const { dismissed } = useDismissedInboxItems();
|
||||
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
|
||||
const { dismissedAtByKey } = useInboxDismissals(companyId);
|
||||
|
||||
const { data: approvals = [] } = useQuery({
|
||||
queryKey: queryKeys.approvals.list(companyId!),
|
||||
@@ -134,8 +194,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
||||
dashboard,
|
||||
heartbeatRuns,
|
||||
mineIssues,
|
||||
dismissed,
|
||||
dismissedAlerts,
|
||||
dismissedAtByKey,
|
||||
}),
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed],
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function TestHarness({
|
||||
onNewIssue,
|
||||
}: {
|
||||
onNewIssue: () => void;
|
||||
}) {
|
||||
useKeyboardShortcuts({
|
||||
enabled: true,
|
||||
onNewIssue,
|
||||
});
|
||||
|
||||
return <div>keyboard shortcuts test</div>;
|
||||
}
|
||||
|
||||
describe("useKeyboardShortcuts", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("ignores events already claimed by another handler", () => {
|
||||
const root = createRoot(container);
|
||||
const onNewIssue = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(<TestHarness onNewIssue={onNewIssue} />);
|
||||
});
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "c",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
event.preventDefault();
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(onNewIssue).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ interface ShortcutHandlers {
|
||||
onNewIssue?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
onTogglePanel?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
@@ -13,16 +14,28 @@ export function useKeyboardShortcuts({
|
||||
onNewIssue,
|
||||
onToggleSidebar,
|
||||
onTogglePanel,
|
||||
onShowShortcuts,
|
||||
}: ShortcutHandlers) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't fire shortcuts when typing in inputs
|
||||
if (isKeyboardShortcutTextInputTarget(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ? → Show keyboard shortcuts cheatsheet
|
||||
if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
onShowShortcuts?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// C → New Issue
|
||||
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
@@ -44,5 +57,5 @@ export function useKeyboardShortcuts({
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel]);
|
||||
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel, onShowShortcuts]);
|
||||
}
|
||||
|
||||
+13
-10
@@ -294,26 +294,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer text effect for active "Working" state */
|
||||
/* Shimmer text effect for active "Working" state — Cursor-style sweep */
|
||||
@keyframes shimmer-text-slide {
|
||||
0% { background-position: 200% center; }
|
||||
100% { background-position: -200% center; }
|
||||
0% { background-position: 100% center; }
|
||||
60% { background-position: 0% center; }
|
||||
100% { background-position: 0% center; }
|
||||
}
|
||||
|
||||
.shimmer-text {
|
||||
--shimmer-base: hsl(var(--foreground) / 0.75);
|
||||
--shimmer-highlight: hsl(var(--foreground) / 0.3);
|
||||
--shimmer-base: var(--foreground);
|
||||
--shimmer-highlight: color-mix(in oklch, var(--foreground) 35%, transparent);
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
var(--shimmer-base) 35%,
|
||||
90deg,
|
||||
var(--shimmer-base) 0%,
|
||||
var(--shimmer-base) 40%,
|
||||
var(--shimmer-highlight) 50%,
|
||||
var(--shimmer-base) 65%
|
||||
var(--shimmer-base) 60%,
|
||||
var(--shimmer-base) 100%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer-text-slide 2.5s ease-in-out infinite;
|
||||
animation: shimmer-text-slide 2.5s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatActivityVerb, formatIssueActivityAction } from "./activity-format";
|
||||
|
||||
describe("activity formatting", () => {
|
||||
const agentMap = new Map<string, Agent>([
|
||||
["agent-reviewer", { id: "agent-reviewer", name: "Reviewer Bot" } as Agent],
|
||||
["agent-approver", { id: "agent-approver", name: "Approver Bot" } as Agent],
|
||||
]);
|
||||
|
||||
it("formats blocker activity using linked issue identifiers", () => {
|
||||
const details = {
|
||||
addedBlockedByIssues: [
|
||||
{ id: "issue-2", identifier: "PAP-22", title: "Blocked task" },
|
||||
],
|
||||
removedBlockedByIssues: [],
|
||||
};
|
||||
|
||||
expect(formatActivityVerb("issue.blockers_updated", details)).toBe("added blocker PAP-22 to");
|
||||
expect(formatIssueActivityAction("issue.blockers_updated", details)).toBe("added blocker PAP-22");
|
||||
});
|
||||
|
||||
it("formats reviewer activity using agent names", () => {
|
||||
const details = {
|
||||
addedParticipants: [
|
||||
{ type: "agent", agentId: "agent-reviewer", userId: null },
|
||||
],
|
||||
removedParticipants: [],
|
||||
};
|
||||
|
||||
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot to");
|
||||
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot");
|
||||
});
|
||||
|
||||
it("formats approver removals using user-aware labels", () => {
|
||||
const details = {
|
||||
addedParticipants: [],
|
||||
removedParticipants: [
|
||||
{ type: "user", agentId: null, userId: "local-board" },
|
||||
],
|
||||
};
|
||||
|
||||
expect(formatActivityVerb("issue.approvers_updated", details)).toBe("removed approver Board from");
|
||||
expect(formatIssueActivityAction("issue.approvers_updated", details)).toBe("removed approver Board");
|
||||
});
|
||||
|
||||
it("falls back to updated wording when reviewers are both added and removed", () => {
|
||||
const details = {
|
||||
addedParticipants: [
|
||||
{ type: "agent", agentId: "agent-reviewer", userId: null },
|
||||
],
|
||||
removedParticipants: [
|
||||
{ type: "agent", agentId: "agent-approver", userId: null },
|
||||
],
|
||||
};
|
||||
|
||||
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers on");
|
||||
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
|
||||
type ActivityDetails = Record<string, unknown> | null | undefined;
|
||||
|
||||
type ActivityParticipant = {
|
||||
type: "agent" | "user";
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
type ActivityIssueReference = {
|
||||
id?: string | null;
|
||||
identifier?: string | null;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
interface ActivityFormatOptions {
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
}
|
||||
|
||||
const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
||||
"issue.created": "created",
|
||||
"issue.updated": "updated",
|
||||
"issue.checked_out": "checked out",
|
||||
"issue.released": "released",
|
||||
"issue.comment_added": "commented on",
|
||||
"issue.attachment_added": "attached file to",
|
||||
"issue.attachment_removed": "removed attachment from",
|
||||
"issue.document_created": "created document for",
|
||||
"issue.document_updated": "updated document on",
|
||||
"issue.document_deleted": "deleted document from",
|
||||
"issue.commented": "commented on",
|
||||
"issue.deleted": "deleted",
|
||||
"agent.created": "created",
|
||||
"agent.updated": "updated",
|
||||
"agent.paused": "paused",
|
||||
"agent.resumed": "resumed",
|
||||
"agent.terminated": "terminated",
|
||||
"agent.key_created": "created API key for",
|
||||
"agent.budget_updated": "updated budget for",
|
||||
"agent.runtime_session_reset": "reset session for",
|
||||
"heartbeat.invoked": "invoked heartbeat for",
|
||||
"heartbeat.cancelled": "cancelled heartbeat for",
|
||||
"approval.created": "requested approval",
|
||||
"approval.approved": "approved",
|
||||
"approval.rejected": "rejected",
|
||||
"project.created": "created",
|
||||
"project.updated": "updated",
|
||||
"project.deleted": "deleted",
|
||||
"goal.created": "created",
|
||||
"goal.updated": "updated",
|
||||
"goal.deleted": "deleted",
|
||||
"cost.reported": "reported cost for",
|
||||
"cost.recorded": "recorded cost for",
|
||||
"company.created": "created company",
|
||||
"company.updated": "updated company",
|
||||
"company.archived": "archived",
|
||||
"company.budget_updated": "updated budget for",
|
||||
};
|
||||
|
||||
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||
"issue.created": "created the issue",
|
||||
"issue.updated": "updated the issue",
|
||||
"issue.checked_out": "checked out the issue",
|
||||
"issue.released": "released the issue",
|
||||
"issue.comment_added": "added a comment",
|
||||
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
||||
"issue.attachment_added": "added an attachment",
|
||||
"issue.attachment_removed": "removed an attachment",
|
||||
"issue.document_created": "created a document",
|
||||
"issue.document_updated": "updated a document",
|
||||
"issue.document_deleted": "deleted a document",
|
||||
"issue.deleted": "deleted the issue",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
"agent.paused": "paused the agent",
|
||||
"agent.resumed": "resumed the agent",
|
||||
"agent.terminated": "terminated the agent",
|
||||
"heartbeat.invoked": "invoked a heartbeat",
|
||||
"heartbeat.cancelled": "cancelled a heartbeat",
|
||||
"approval.created": "requested approval",
|
||||
"approval.approved": "approved",
|
||||
"approval.rejected": "rejected",
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function humanizeValue(value: unknown): string {
|
||||
if (typeof value !== "string") return String(value ?? "none");
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function isActivityParticipant(value: unknown): value is ActivityParticipant {
|
||||
const record = asRecord(value);
|
||||
if (!record) return false;
|
||||
return record.type === "agent" || record.type === "user";
|
||||
}
|
||||
|
||||
function isActivityIssueReference(value: unknown): value is ActivityIssueReference {
|
||||
return asRecord(value) !== null;
|
||||
}
|
||||
|
||||
function readParticipants(details: ActivityDetails, key: string): ActivityParticipant[] {
|
||||
const value = details?.[key];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isActivityParticipant);
|
||||
}
|
||||
|
||||
function readIssueReferences(details: ActivityDetails, key: string): ActivityIssueReference[] {
|
||||
const value = details?.[key];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isActivityIssueReference);
|
||||
}
|
||||
|
||||
function formatUserLabel(userId: string | null | undefined, currentUserId?: string | null): string {
|
||||
if (!userId || userId === "local-board") return "Board";
|
||||
if (currentUserId && userId === currentUserId) return "You";
|
||||
return `user ${userId.slice(0, 5)}`;
|
||||
}
|
||||
|
||||
function formatParticipantLabel(participant: ActivityParticipant, options: ActivityFormatOptions): string {
|
||||
if (participant.type === "agent") {
|
||||
const agentId = participant.agentId ?? "";
|
||||
return options.agentMap?.get(agentId)?.name ?? "agent";
|
||||
}
|
||||
return formatUserLabel(participant.userId, options.currentUserId);
|
||||
}
|
||||
|
||||
function formatIssueReferenceLabel(reference: ActivityIssueReference): string {
|
||||
if (reference.identifier) return reference.identifier;
|
||||
if (reference.title) return reference.title;
|
||||
if (reference.id) return reference.id.slice(0, 8);
|
||||
return "issue";
|
||||
}
|
||||
|
||||
function formatChangedEntityLabel(
|
||||
singular: string,
|
||||
plural: string,
|
||||
labels: string[],
|
||||
): string {
|
||||
if (labels.length <= 0) return plural;
|
||||
if (labels.length === 1) return `${singular} ${labels[0]}`;
|
||||
return `${labels.length} ${plural}`;
|
||||
}
|
||||
|
||||
function formatIssueUpdatedVerb(details: ActivityDetails): string | null {
|
||||
if (!details) return null;
|
||||
const previous = asRecord(details._previous) ?? {};
|
||||
if (details.status !== undefined) {
|
||||
const from = previous.status;
|
||||
return from
|
||||
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
|
||||
: `changed status to ${humanizeValue(details.status)} on`;
|
||||
}
|
||||
if (details.priority !== undefined) {
|
||||
const from = previous.priority;
|
||||
return from
|
||||
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
|
||||
: `changed priority to ${humanizeValue(details.priority)} on`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatIssueUpdatedAction(details: ActivityDetails): string | null {
|
||||
if (!details) return null;
|
||||
const previous = asRecord(details._previous) ?? {};
|
||||
const parts: string[] = [];
|
||||
|
||||
if (details.status !== undefined) {
|
||||
const from = previous.status;
|
||||
parts.push(
|
||||
from
|
||||
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
|
||||
: `changed the status to ${humanizeValue(details.status)}`,
|
||||
);
|
||||
}
|
||||
if (details.priority !== undefined) {
|
||||
const from = previous.priority;
|
||||
parts.push(
|
||||
from
|
||||
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
|
||||
: `changed the priority to ${humanizeValue(details.priority)}`,
|
||||
);
|
||||
}
|
||||
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
||||
parts.push(details.assigneeAgentId || details.assigneeUserId ? "assigned the issue" : "unassigned the issue");
|
||||
}
|
||||
if (details.title !== undefined) parts.push("updated the title");
|
||||
if (details.description !== undefined) parts.push("updated the description");
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
function formatStructuredIssueChange(input: {
|
||||
action: string;
|
||||
details: ActivityDetails;
|
||||
options: ActivityFormatOptions;
|
||||
forIssueDetail: boolean;
|
||||
}): string | null {
|
||||
const details = input.details;
|
||||
if (!details) return null;
|
||||
|
||||
if (input.action === "issue.blockers_updated") {
|
||||
const added = readIssueReferences(details, "addedBlockedByIssues").map(formatIssueReferenceLabel);
|
||||
const removed = readIssueReferences(details, "removedBlockedByIssues").map(formatIssueReferenceLabel);
|
||||
if (added.length > 0 && removed.length === 0) {
|
||||
const changed = formatChangedEntityLabel("blocker", "blockers", added);
|
||||
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
||||
}
|
||||
if (removed.length > 0 && added.length === 0) {
|
||||
const changed = formatChangedEntityLabel("blocker", "blockers", removed);
|
||||
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
||||
}
|
||||
return input.forIssueDetail ? "updated blockers" : "updated blockers on";
|
||||
}
|
||||
|
||||
if (input.action === "issue.reviewers_updated" || input.action === "issue.approvers_updated") {
|
||||
const added = readParticipants(details, "addedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
||||
const removed = readParticipants(details, "removedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
||||
const singular = input.action === "issue.reviewers_updated" ? "reviewer" : "approver";
|
||||
const plural = input.action === "issue.reviewers_updated" ? "reviewers" : "approvers";
|
||||
if (added.length > 0 && removed.length === 0) {
|
||||
const changed = formatChangedEntityLabel(singular, plural, added);
|
||||
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
||||
}
|
||||
if (removed.length > 0 && added.length === 0) {
|
||||
const changed = formatChangedEntityLabel(singular, plural, removed);
|
||||
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
||||
}
|
||||
return input.forIssueDetail ? `updated ${plural}` : `updated ${plural} on`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatActivityVerb(
|
||||
action: string,
|
||||
details?: Record<string, unknown> | null,
|
||||
options: ActivityFormatOptions = {},
|
||||
): string {
|
||||
if (action === "issue.updated") {
|
||||
const issueUpdatedVerb = formatIssueUpdatedVerb(details);
|
||||
if (issueUpdatedVerb) return issueUpdatedVerb;
|
||||
}
|
||||
|
||||
const structuredChange = formatStructuredIssueChange({
|
||||
action,
|
||||
details,
|
||||
options,
|
||||
forIssueDetail: false,
|
||||
});
|
||||
if (structuredChange) return structuredChange;
|
||||
|
||||
return ACTIVITY_ROW_VERBS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
export function formatIssueActivityAction(
|
||||
action: string,
|
||||
details?: Record<string, unknown> | null,
|
||||
options: ActivityFormatOptions = {},
|
||||
): string {
|
||||
if (action === "issue.updated") {
|
||||
const issueUpdatedAction = formatIssueUpdatedAction(details);
|
||||
if (issueUpdatedAction) return issueUpdatedAction;
|
||||
}
|
||||
|
||||
const structuredChange = formatStructuredIssueChange({
|
||||
action,
|
||||
details,
|
||||
options,
|
||||
forIssueDetail: true,
|
||||
});
|
||||
if (structuredChange) return structuredChange;
|
||||
|
||||
if (
|
||||
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
|
||||
details
|
||||
) {
|
||||
const key = typeof details.key === "string" ? details.key : "document";
|
||||
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
|
||||
return `${ISSUE_ACTIVITY_LABELS[action] ?? action} ${key}${title}`;
|
||||
}
|
||||
|
||||
return ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
@@ -9,16 +9,23 @@ import {
|
||||
describe("company routes", () => {
|
||||
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/issues")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
||||
"/PAP/execution-workspaces/workspace-123",
|
||||
);
|
||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123/issues", "PAP")).toBe(
|
||||
"/PAP/execution-workspaces/workspace-123/issues",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
||||
"/execution-workspaces/workspace-123",
|
||||
);
|
||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/configuration")).toBe(
|
||||
"/execution-workspaces/workspace-123/configuration",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
buildInboxDismissedAtByKey,
|
||||
computeInboxBadgeData,
|
||||
getAvailableInboxIssueColumns,
|
||||
getApprovalsForTab,
|
||||
@@ -19,11 +20,13 @@ import {
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getRecentTouchedIssues,
|
||||
getUnreadTouchedIssues,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxIssueColumns,
|
||||
loadLastInboxTab,
|
||||
normalizeInboxIssueColumns,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
resolveInboxNestingEnabled,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxIssueColumns,
|
||||
@@ -286,7 +289,8 @@ describe("inbox helpers", () => {
|
||||
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
|
||||
],
|
||||
mineIssues: [makeIssue("1", true)],
|
||||
dismissed: new Set<string>(),
|
||||
dismissedAlerts: new Set<string>(),
|
||||
dismissedAtByKey: new Map<string, number>(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -306,7 +310,8 @@ describe("inbox helpers", () => {
|
||||
dashboard,
|
||||
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
|
||||
mineIssues: [],
|
||||
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
|
||||
dismissedAlerts: new Set<string>(["alert:budget", "alert:agent-errors"]),
|
||||
dismissedAtByKey: new Map<string, number>([["run:run-1", new Date("2026-03-11T00:00:00.000Z").getTime()]]),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -326,7 +331,8 @@ describe("inbox helpers", () => {
|
||||
dashboard,
|
||||
heartbeatRuns: [],
|
||||
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
|
||||
dismissed: new Set<string>(),
|
||||
dismissedAlerts: new Set<string>(),
|
||||
dismissedAtByKey: new Map(),
|
||||
});
|
||||
|
||||
expect(result.mineIssues).toBe(1);
|
||||
@@ -334,6 +340,35 @@ describe("inbox helpers", () => {
|
||||
expect(result.inbox).toBe(3);
|
||||
});
|
||||
|
||||
it("resurfaces non-issue items when they change after dismissal", () => {
|
||||
const dismissedAtByKey = buildInboxDismissedAtByKey([
|
||||
{
|
||||
id: "dismissal-1",
|
||||
companyId: "company-1",
|
||||
userId: "user-1",
|
||||
itemKey: "approval:approval-1",
|
||||
dismissedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
isInboxEntityDismissed(
|
||||
dismissedAtByKey,
|
||||
"approval:approval-1",
|
||||
new Date("2026-03-11T00:30:00.000Z"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isInboxEntityDismissed(
|
||||
dismissedAtByKey,
|
||||
"approval:approval-1",
|
||||
new Date("2026-03-11T01:30:00.000Z"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps read issues in the touched list but excludes them from unread counts", () => {
|
||||
const issues = [makeIssue("1", true), makeIssue("2", false)];
|
||||
|
||||
@@ -518,6 +553,19 @@ describe("inbox helpers", () => {
|
||||
expect(loadLastInboxTab()).toBe("all");
|
||||
});
|
||||
|
||||
it("keeps nesting enabled on desktop when the saved preference is on", () => {
|
||||
expect(resolveInboxNestingEnabled(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("forces nesting off on mobile even when the saved preference is on", () => {
|
||||
expect(resolveInboxNestingEnabled(true, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps nesting off when the saved preference is off", () => {
|
||||
expect(resolveInboxNestingEnabled(false, false)).toBe(false);
|
||||
expect(resolveInboxNestingEnabled(false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults issue columns to the current inbox layout", () => {
|
||||
expect(loadInboxIssueColumns()).toEqual(DEFAULT_INBOX_ISSUE_COLUMNS);
|
||||
});
|
||||
|
||||
+123
-11
@@ -1,4 +1,11 @@
|
||||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import type {
|
||||
Approval,
|
||||
DashboardSummary,
|
||||
HeartbeatRun,
|
||||
InboxDismissal,
|
||||
Issue,
|
||||
JoinRequest,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export const RECENT_ISSUES_LIMIT = 100;
|
||||
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||
@@ -7,6 +14,7 @@ export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
|
||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
||||
@@ -43,16 +51,19 @@ export interface InboxBadgeData {
|
||||
alerts: number;
|
||||
}
|
||||
|
||||
export function loadDismissedInboxItems(): Set<string> {
|
||||
export function loadDismissedInboxAlerts(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.startsWith("alert:")));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDismissedInboxItems(ids: Set<string>) {
|
||||
export function saveDismissedInboxAlerts(ids: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
||||
} catch {
|
||||
@@ -60,6 +71,22 @@ export function saveDismissedInboxItems(ids: Set<string>) {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInboxDismissedAtByKey(dismissals: InboxDismissal[]): Map<string, number> {
|
||||
return new Map(
|
||||
dismissals.map((dismissal) => [dismissal.itemKey, normalizeTimestamp(dismissal.dismissedAt)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function isInboxEntityDismissed(
|
||||
dismissedAtByKey: ReadonlyMap<string, number>,
|
||||
itemKey: string,
|
||||
activityAt: string | Date | null | undefined,
|
||||
): boolean {
|
||||
const dismissedAt = dismissedAtByKey.get(itemKey);
|
||||
if (dismissedAt == null) return false;
|
||||
return dismissedAt >= normalizeTimestamp(activityAt);
|
||||
}
|
||||
|
||||
export function loadReadInboxItems(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(READ_ITEMS_KEY);
|
||||
@@ -151,6 +178,27 @@ export function resolveIssueWorkspaceName(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadInboxNesting(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_NESTING_KEY);
|
||||
return raw !== "false";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInboxNesting(enabled: boolean) {
|
||||
try {
|
||||
localStorage.setItem(INBOX_NESTING_KEY, String(enabled));
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveInboxNestingEnabled(preferenceEnabled: boolean, isMobile: boolean): boolean {
|
||||
return preferenceEnabled && !isMobile;
|
||||
}
|
||||
|
||||
export function loadLastInboxTab(): InboxTab {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
||||
@@ -314,6 +362,68 @@ export function getInboxWorkItems({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups parent-child issues in a flat InboxWorkItem list.
|
||||
*
|
||||
* - Children whose parent is also in the list are removed from the top level
|
||||
* and stored in `childrenByIssueId`.
|
||||
* - The parent's sort timestamp becomes max(parent, children) so that a group
|
||||
* with a recently-updated child floats to the top.
|
||||
* - If a parent is absent (e.g. archived), children remain as independent roots.
|
||||
*/
|
||||
export function buildInboxNesting(items: InboxWorkItem[]): {
|
||||
displayItems: InboxWorkItem[];
|
||||
childrenByIssueId: Map<string, Issue[]>;
|
||||
} {
|
||||
const issueItems: (InboxWorkItem & { kind: "issue" })[] = [];
|
||||
const nonIssueItems: InboxWorkItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "issue") issueItems.push(item as InboxWorkItem & { kind: "issue" });
|
||||
else nonIssueItems.push(item);
|
||||
}
|
||||
|
||||
const issueIdSet = new Set(issueItems.map((i) => i.issue.id));
|
||||
const childrenByIssueId = new Map<string, Issue[]>();
|
||||
const childIds = new Set<string>();
|
||||
|
||||
for (const item of issueItems) {
|
||||
const { issue } = item;
|
||||
if (issue.parentId && issueIdSet.has(issue.parentId)) {
|
||||
childIds.add(issue.id);
|
||||
const arr = childrenByIssueId.get(issue.parentId) ?? [];
|
||||
arr.push(issue);
|
||||
childrenByIssueId.set(issue.parentId, arr);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each child list by most recent activity
|
||||
for (const children of childrenByIssueId.values()) {
|
||||
children.sort(sortIssuesByMostRecentActivity);
|
||||
}
|
||||
|
||||
// Build root issue items with group-adjusted timestamps
|
||||
const rootIssueItems: InboxWorkItem[] = issueItems
|
||||
.filter((item) => !childIds.has(item.issue.id))
|
||||
.map((item) => {
|
||||
const children = childrenByIssueId.get(item.issue.id);
|
||||
if (!children?.length) return item;
|
||||
const maxChildTs = Math.max(...children.map(issueLastActivityTimestamp));
|
||||
return { ...item, timestamp: Math.max(item.timestamp, maxChildTs) };
|
||||
});
|
||||
|
||||
// Merge and re-sort
|
||||
const displayItems = [...rootIssueItems, ...nonIssueItems].sort((a, b) => {
|
||||
const diff = b.timestamp - a.timestamp;
|
||||
if (diff !== 0) return diff;
|
||||
if (a.kind === "issue" && b.kind === "issue") {
|
||||
return sortIssuesByMostRecentActivity(a.issue, b.issue);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { displayItems, childrenByIssueId };
|
||||
}
|
||||
|
||||
export function shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems,
|
||||
@@ -342,25 +452,27 @@ export function computeInboxBadgeData({
|
||||
dashboard,
|
||||
heartbeatRuns,
|
||||
mineIssues,
|
||||
dismissed,
|
||||
dismissedAlerts,
|
||||
dismissedAtByKey,
|
||||
}: {
|
||||
approvals: Approval[];
|
||||
joinRequests: JoinRequest[];
|
||||
dashboard: DashboardSummary | undefined;
|
||||
heartbeatRuns: HeartbeatRun[];
|
||||
mineIssues: Issue[];
|
||||
dismissed: Set<string>;
|
||||
dismissedAlerts: Set<string>;
|
||||
dismissedAtByKey: ReadonlyMap<string, number>;
|
||||
}): InboxBadgeData {
|
||||
const actionableApprovals = approvals.filter(
|
||||
(approval) =>
|
||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
|
||||
!dismissed.has(`approval:${approval.id}`),
|
||||
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
|
||||
).length;
|
||||
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
||||
(run) => !dismissed.has(`run:${run.id}`),
|
||||
(run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt),
|
||||
).length;
|
||||
const visibleJoinRequests = joinRequests.filter(
|
||||
(jr) => !dismissed.has(`join:${jr.id}`),
|
||||
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
|
||||
).length;
|
||||
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
|
||||
const agentErrorCount = dashboard?.agents.error ?? 0;
|
||||
@@ -369,11 +481,11 @@ export function computeInboxBadgeData({
|
||||
const showAggregateAgentError =
|
||||
agentErrorCount > 0 &&
|
||||
failedRuns === 0 &&
|
||||
!dismissed.has("alert:agent-errors");
|
||||
!dismissedAlerts.has("alert:agent-errors");
|
||||
const showBudgetAlert =
|
||||
monthBudgetCents > 0 &&
|
||||
monthUtilizationPercent >= 80 &&
|
||||
!dismissed.has("alert:budget");
|
||||
!dismissedAlerts.has("alert:budget");
|
||||
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
|
||||
|
||||
return {
|
||||
|
||||
@@ -130,6 +130,43 @@ describe("buildAssistantPartsFromTranscript", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a completed tool-only segment as resolved once a tool_result arrives", () => {
|
||||
const result = buildAssistantPartsFromTranscript([
|
||||
{ kind: "thinking", ts: "2026-04-06T12:00:00.000Z", text: "Checking the task." },
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-06T12:00:01.000Z",
|
||||
name: "search",
|
||||
toolUseId: "tool-1",
|
||||
input: { query: "paperclip" },
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: "2026-04-06T12:00:02.000Z",
|
||||
toolUseId: "tool-1",
|
||||
content: "search completed",
|
||||
isError: false,
|
||||
},
|
||||
{ kind: "assistant", ts: "2026-04-06T12:00:03.000Z", text: "Found the relevant code." },
|
||||
]);
|
||||
|
||||
expect(result.parts).toMatchObject([
|
||||
{ type: "reasoning", text: "Checking the task." },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "search",
|
||||
result: "search completed",
|
||||
isError: false,
|
||||
},
|
||||
{ type: "text", text: "Found the relevant code." },
|
||||
]);
|
||||
expect(result.segments).toEqual([{
|
||||
startMs: new Date("2026-04-06T12:00:00.000Z").getTime(),
|
||||
endMs: new Date("2026-04-06T12:00:02.000Z").getTime(),
|
||||
}]);
|
||||
});
|
||||
|
||||
it("keeps run errors while suppressing init and system transcript noise", () => {
|
||||
const result = buildAssistantPartsFromTranscript([
|
||||
{
|
||||
@@ -270,7 +307,7 @@ describe("buildIssueChatMessages", () => {
|
||||
"system:activity:event-1",
|
||||
"user:comment-1",
|
||||
"assistant:comment-2",
|
||||
"assistant:live-run:run-live-1",
|
||||
"assistant:run-assistant:run-live-1",
|
||||
]);
|
||||
|
||||
const liveRunMessage = messages.at(-1);
|
||||
@@ -316,7 +353,7 @@ describe("buildIssueChatMessages", () => {
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "historical-run:run-history-1",
|
||||
id: "run-assistant:run-history-1",
|
||||
role: "assistant",
|
||||
status: { type: "complete", reason: "stop" },
|
||||
metadata: {
|
||||
@@ -333,6 +370,64 @@ describe("buildIssueChatMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the same assistant message id when a live run becomes a cancelled historical run", () => {
|
||||
const liveMessages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [
|
||||
{
|
||||
id: "run-1",
|
||||
status: "running",
|
||||
invocationSource: "manual",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-06T12:01:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-06T12:01:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
},
|
||||
],
|
||||
transcriptsByRunId: new Map([
|
||||
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-1",
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const cancelledMessages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [
|
||||
{
|
||||
runId: "run-1",
|
||||
status: "cancelled",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:01:08.000Z"),
|
||||
},
|
||||
],
|
||||
liveRuns: [],
|
||||
transcriptsByRunId: new Map([
|
||||
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-1",
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(liveMessages).toHaveLength(1);
|
||||
expect(cancelledMessages).toHaveLength(1);
|
||||
expect(liveMessages[0]).toMatchObject({ id: "run-assistant:run-1", status: { type: "running" } });
|
||||
expect(cancelledMessages[0]).toMatchObject({
|
||||
id: "run-assistant:run-1",
|
||||
status: { type: "complete", reason: "stop" },
|
||||
metadata: { custom: { runStatus: "cancelled" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("can keep succeeded runs without transcript output for embedded run feeds", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
|
||||
@@ -410,7 +410,7 @@ function createHistoricalTranscriptMessage(args: {
|
||||
: [];
|
||||
|
||||
const message: ThreadAssistantMessage = {
|
||||
id: `historical-run:${run.runId}`,
|
||||
id: `run-assistant:${run.runId}`,
|
||||
role: "assistant",
|
||||
createdAt: toDate(run.startedAt ?? run.createdAt),
|
||||
content,
|
||||
@@ -593,25 +593,20 @@ function normalizeLiveRuns(
|
||||
function createLiveRunMessage(args: {
|
||||
run: LiveRunForIssue;
|
||||
transcript: readonly IssueChatTranscriptEntry[];
|
||||
hasOutput: boolean;
|
||||
}) {
|
||||
const { run, transcript, hasOutput } = args;
|
||||
const { run, transcript } = args;
|
||||
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
|
||||
const waitingText =
|
||||
run.status === "queued"
|
||||
? "Queued..."
|
||||
: hasOutput
|
||||
: parts.length > 0
|
||||
? ""
|
||||
: "Working...";
|
||||
|
||||
const content = parts.length > 0
|
||||
? parts
|
||||
: waitingText
|
||||
? [{ type: "text", text: waitingText } satisfies TextMessagePart]
|
||||
: [];
|
||||
const content = parts;
|
||||
|
||||
const message: ThreadAssistantMessage = {
|
||||
id: `live-run:${run.id}`,
|
||||
id: `run-assistant:${run.id}`,
|
||||
role: "assistant",
|
||||
createdAt: toDate(run.startedAt ?? run.createdAt),
|
||||
content,
|
||||
@@ -684,7 +679,10 @@ export function buildIssueChatMessages(args: {
|
||||
for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
|
||||
const transcript = transcriptsByRunId?.get(run.runId) ?? [];
|
||||
const hasRunOutput = transcript.length > 0 || (hasOutputForRun?.(run.runId) ?? false);
|
||||
if (hasRunOutput) {
|
||||
if (hasRunOutput || run.status !== "succeeded") {
|
||||
// Always use the transcript message for non-succeeded runs (even before
|
||||
// transcript data loads) so the message type and fold header are stable
|
||||
// from initial render — avoids a flash when transcripts arrive later.
|
||||
orderedMessages.push({
|
||||
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
|
||||
order: 2,
|
||||
@@ -697,7 +695,7 @@ export function buildIssueChatMessages(args: {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (run.status === "succeeded" && !includeSucceededRunsWithoutOutput) continue;
|
||||
if (!includeSucceededRunsWithoutOutput) continue;
|
||||
orderedMessages.push({
|
||||
createdAtMs: toTimestamp(runTimestamp(run)),
|
||||
order: 2,
|
||||
@@ -712,7 +710,6 @@ export function buildIssueChatMessages(args: {
|
||||
message: createLiveRunMessage({
|
||||
run,
|
||||
transcript: transcriptsByRunId?.get(run.id) ?? [],
|
||||
hasOutput: hasOutputForRun?.(run.id) ?? false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
resolveIssueDetailGoKeyAction,
|
||||
resolveInboxQuickArchiveKeyAction,
|
||||
} from "./keyboardShortcuts";
|
||||
|
||||
@@ -54,7 +55,7 @@ describe("keyboardShortcuts helpers", () => {
|
||||
})).toBe("archive");
|
||||
});
|
||||
|
||||
it("disarms on the first non-y keypress", () => {
|
||||
it("ignores non-y keypresses", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
@@ -66,7 +67,7 @@ describe("keyboardShortcuts helpers", () => {
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("disarm");
|
||||
})).toBe("ignore");
|
||||
});
|
||||
|
||||
it("stays inert for modifier combos before a real keypress", () => {
|
||||
@@ -95,7 +96,7 @@ describe("keyboardShortcuts helpers", () => {
|
||||
})).toBe("ignore");
|
||||
});
|
||||
|
||||
it("disarms instead of archiving when typing into an editor", () => {
|
||||
it("ignores input typing instead of archiving", () => {
|
||||
const input = document.createElement("input");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
@@ -107,6 +108,66 @@ describe("keyboardShortcuts helpers", () => {
|
||||
altKey: false,
|
||||
target: input,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("ignore");
|
||||
});
|
||||
|
||||
it("arms go-to-inbox on a clean g press", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveIssueDetailGoKeyAction({
|
||||
armed: false,
|
||||
defaultPrevented: false,
|
||||
key: "g",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("arm");
|
||||
});
|
||||
|
||||
it("navigates to inbox on i after g", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveIssueDetailGoKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "i",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("navigate_inbox");
|
||||
});
|
||||
|
||||
it("focuses the comment composer on c after g", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveIssueDetailGoKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "c",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("focus_comment");
|
||||
});
|
||||
|
||||
it("disarms go-to-inbox instead of firing from an editor", () => {
|
||||
const input = document.createElement("textarea");
|
||||
|
||||
expect(resolveIssueDetailGoKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "i",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: input,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("disarm");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
|
||||
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
||||
|
||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||
export type IssueDetailGoKeyAction = "ignore" | "arm" | "navigate_inbox" | "focus_comment" | "disarm";
|
||||
|
||||
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
@@ -46,9 +47,42 @@ export function resolveInboxQuickArchiveKeyAction({
|
||||
hasOpenDialog: boolean;
|
||||
}): InboxQuickArchiveKeyAction {
|
||||
if (!armed) return "ignore";
|
||||
if (defaultPrevented) return "disarm";
|
||||
if (defaultPrevented) return "ignore";
|
||||
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
||||
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "disarm";
|
||||
if (key === "y") return "archive";
|
||||
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "ignore";
|
||||
if (key.toLowerCase() === "y") return "archive";
|
||||
return "ignore";
|
||||
}
|
||||
|
||||
export function resolveIssueDetailGoKeyAction({
|
||||
armed,
|
||||
defaultPrevented,
|
||||
key,
|
||||
metaKey,
|
||||
ctrlKey,
|
||||
altKey,
|
||||
target,
|
||||
hasOpenDialog,
|
||||
}: {
|
||||
armed: boolean;
|
||||
defaultPrevented: boolean;
|
||||
key: string;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
target: EventTarget | null;
|
||||
hasOpenDialog: boolean;
|
||||
}): IssueDetailGoKeyAction {
|
||||
if (defaultPrevented) return armed ? "disarm" : "ignore";
|
||||
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
||||
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) {
|
||||
return armed ? "disarm" : "ignore";
|
||||
}
|
||||
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (!armed) return normalizedKey === "g" ? "arm" : "ignore";
|
||||
if (normalizedKey === "i") return "navigate_inbox";
|
||||
if (normalizedKey === "c") return "focus_comment";
|
||||
if (normalizedKey === "g") return "arm";
|
||||
return "disarm";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
scheduleMainContentFocus,
|
||||
shouldFocusMainContentAfterNavigation,
|
||||
} from "./main-content-focus";
|
||||
|
||||
describe("main-content-focus", () => {
|
||||
let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
|
||||
let originalCancelAnimationFrame: typeof window.cancelAnimationFrame;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = ((handle: number) => window.clearTimeout(handle)) as typeof window.cancelAnimationFrame;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("prefers the main content when navigation leaves focus outside it", async () => {
|
||||
const sidebarButton = document.createElement("button");
|
||||
const main = document.createElement("main");
|
||||
main.tabIndex = -1;
|
||||
document.body.append(sidebarButton, main);
|
||||
sidebarButton.focus();
|
||||
|
||||
scheduleMainContentFocus(main);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
expect(document.activeElement).toBe(main);
|
||||
});
|
||||
|
||||
it("does not steal focus from an active element already inside main content", async () => {
|
||||
const main = document.createElement("main");
|
||||
const input = document.createElement("input");
|
||||
main.tabIndex = -1;
|
||||
main.appendChild(input);
|
||||
document.body.append(main);
|
||||
input.focus();
|
||||
|
||||
scheduleMainContentFocus(main);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("treats disconnected elements as needing main-content focus", () => {
|
||||
const main = document.createElement("main");
|
||||
main.tabIndex = -1;
|
||||
document.body.append(main);
|
||||
|
||||
const staleButton = document.createElement("button");
|
||||
staleButton.focus();
|
||||
|
||||
expect(shouldFocusMainContentAfterNavigation(main, staleButton)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export function shouldFocusMainContentAfterNavigation(
|
||||
mainElement: HTMLElement | null,
|
||||
activeElement: Element | null,
|
||||
): boolean {
|
||||
if (!(mainElement instanceof HTMLElement)) return false;
|
||||
if (!(activeElement instanceof HTMLElement)) return true;
|
||||
if (!document.contains(activeElement)) return true;
|
||||
if (activeElement === document.body || activeElement === document.documentElement) return true;
|
||||
return !mainElement.contains(activeElement);
|
||||
}
|
||||
|
||||
export function scheduleMainContentFocus(mainElement: HTMLElement | null): () => void {
|
||||
if (!(mainElement instanceof HTMLElement)) return () => {};
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
if (!shouldFocusMainContentAfterNavigation(mainElement, document.activeElement)) return;
|
||||
mainElement.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildNewAgentRuntimeConfig } from "./new-agent-runtime-config";
|
||||
|
||||
describe("buildNewAgentRuntimeConfig", () => {
|
||||
it("defaults new agents to no timer heartbeat", () => {
|
||||
expect(buildNewAgentRuntimeConfig()).toEqual({
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
intervalSec: 300,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 10,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit heartbeat settings", () => {
|
||||
expect(
|
||||
buildNewAgentRuntimeConfig({
|
||||
heartbeatEnabled: true,
|
||||
intervalSec: 3600,
|
||||
}),
|
||||
).toEqual({
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalSec: 3600,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 10,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user