Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
55a4099
Fix routine_execution sibling uniqueness wedge (GLA-291)
jqueguiner May 8, 2026
e585e9c
Allow role=security cross-assignee comments on critical/high issues (…
jqueguiner May 8, 2026
f2620cf
Restrict role=security comment bypass to allowlist (GLA-444)
jqueguiner May 8, 2026
c0937bb
Suppress long_active_duration reviews for delegated work (GLA-599)
jqueguiner May 8, 2026
a720b44
GLA-607: wire listUnresolvedBlockerIssueIds and trim duplicate suppre…
jqueguiner May 8, 2026
cfbde05
fix(board-report): source notifier.env before const declarations
jqueguiner May 8, 2026
1c68979
feat(plugin): scaffold paperclip-github-sync (GLA-761)
jqueguiner May 8, 2026
aabac36
feat(plugin-github-sync): add GitHub client unit tests + fix goal.upd…
jqueguiner May 8, 2026
e9c2ef4
feat(plugin-github-sync): GLA-810 goal-subtree filter unit tests + RE…
jqueguiner May 8, 2026
24ab75b
feat(plugin-github-sync): GLA-813 sync engine + idempotency + payload…
jqueguiner May 8, 2026
084bda9
asset-library: scaffold Next.js app on :7700 with pm2 + launchd autos…
jqueguiner May 8, 2026
64e1e7d
feat(GLA-814): admin API endpoints + UI section for GitHub sync integ…
jqueguiner May 8, 2026
3580c7f
asset-library: auto-build wrapper start.sh, soft-fail on rebuild errors
jqueguiner May 8, 2026
8cc1bda
GLA-987: forbidden-source upload scan in asset library
jqueguiner May 8, 2026
d9311d5
GLA-988: route [review-and-ship]/[marketing-asset] emails to asset li…
jqueguiner May 8, 2026
770c40f
fix(github-sync): write pluginLogs on sync-to-github so lastSyncAt po…
jqueguiner May 8, 2026
db1fedd
feat(routines): allow current owner to reassign routine to any agent
jqueguiner May 9, 2026
970b83b
docs(routines): note owner-initiated reassignment in auth rule comment
jqueguiner May 9, 2026
4510c46
GLA-985: approve/reject buttons + provenance hard blocks in asset lib…
jqueguiner May 9, 2026
356e1d1
GLA-1026: asset-library smoke script proves build+start chain
jqueguiner May 9, 2026
2dc8bc0
GLA-1048: routine-owner cancel carve-out for stale routine_execution …
jqueguiner May 9, 2026
8d97a44
GLA-1064: tasks:cancel_routine_execution permission lets reaper cance…
jqueguiner May 9, 2026
08d824b
docs(plugin-github-sync): add user-facing README, CHANGELOG, and reco…
jqueguiner May 9, 2026
372e250
GLA-1066/GLA-873: company-level shared_instructions schema, API, hear…
jqueguiner May 9, 2026
4a7b339
GLA-1076: validate token against repo before persisting GitHub integr…
jqueguiner May 9, 2026
5f2737d
test(GLA-1074): regression test — PAT must not appear in 502 body, la…
jqueguiner May 9, 2026
31be8ee
security: reject dryRun:false with empty syncedGoalIds (GLA-1077 MED-3)
jqueguiner May 9, 2026
d0bb43f
feat(GLA-1098): asset-library auto-rebuild watcher + chunk-error UI +…
jqueguiner May 9, 2026
2747059
feat(GLA-1102): asset-library red banner + auto-resolve for workspace…
jqueguiner May 9, 2026
5b0a503
feat(GLA-1114): issue-level approve gate — block approve when no visu…
jqueguiner May 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/asset-library-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Asset Library Smoke

on:
pull_request:
branches: [master]
paths:
- "scripts/asset-library/**"
- ".github/workflows/asset-library-smoke.yml"

jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 15

defaults:
run:
working-directory: scripts/asset-library

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: scripts/asset-library/package-lock.json

- name: Install
run: npm ci

- name: Build
run: npm run build
env:
NODE_ENV: production

- name: Start
run: node node_modules/.bin/next start -p 7700 &
env:
NODE_ENV: production

- name: Wait for server (30s)
run: |
for i in $(seq 1 30); do
if curl -sf -o /dev/null http://127.0.0.1:7700/; then
echo "server up after ${i}s"
exit 0
fi
sleep 1
done
echo "FAIL: server did not respond within 30s" >&2
exit 1

- name: Smoke — root (expect 200)
run: |
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:7700/)
[ "$code" = "200" ] || { echo "root: expected 200, got $code" >&2; exit 1; }

- name: Smoke — /asset/:id (expect 200, graceful not-found page)
run: |
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:7700/asset/ci-smoke-placeholder)
[ "$code" = "200" ] || { echo "/asset/ci-smoke-placeholder: expected 200, got $code" >&2; exit 1; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- Tighten the routine_execution open-issue unique index so it gates duplicate
-- siblings at INSERT time, even before execution_run_id has been populated by
-- the heartbeat dispatcher. Previously the partial predicate required
-- execution_run_id IS NOT NULL, which left a window where two siblings sharing
-- (company_id, origin_id, origin_fingerprint) could be created with null
-- execution_run_id. A later UPDATE that populated execution_run_id (and kept
-- the row in the index) would then fail with 23505 — and every subsequent
-- write that left the row in the index, including reaper PATCHes setting
-- status='cancelled', would fail too. See GLA-281 / GLA-291.

-- Step 1: Hide newer duplicates so the tightened predicate has at most one row
-- per (company_id, origin_id, origin_fingerprint). Hiding (rather than
-- cancelling) keeps the rows recoverable for forensics. The oldest row is
-- preserved to keep activity history attached to the original issue id.
WITH ranked AS (
SELECT
"id",
row_number() OVER (
PARTITION BY "company_id", "origin_id", "origin_fingerprint"
ORDER BY "created_at" ASC, "id" ASC
) AS rn
FROM "issues"
WHERE "origin_kind" = 'routine_execution'
AND "origin_id" IS NOT NULL
AND "hidden_at" IS NULL
AND "status" IN ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')
)
UPDATE "issues"
SET
"hidden_at" = now(),
"updated_at" = now()
WHERE "id" IN (SELECT "id" FROM ranked WHERE rn > 1);
--> statement-breakpoint

-- Step 2: Recreate the partial unique index without the
-- execution_run_id IS NOT NULL clause.
DROP INDEX IF EXISTS "issues_open_routine_execution_uq";--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "issues_open_routine_execution_uq" ON "issues" USING btree ("company_id","origin_kind","origin_id","origin_fingerprint") WHERE "issues"."origin_kind" = 'routine_execution'
and "issues"."origin_id" is not null
and "issues"."hidden_at" is null
and "issues"."status" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked');

-- Rollback (manual; not a drizzle automatic down):
-- DROP INDEX IF EXISTS "issues_open_routine_execution_uq";
-- CREATE UNIQUE INDEX "issues_open_routine_execution_uq" ON "issues" USING btree ("company_id","origin_kind","origin_id","origin_fingerprint") WHERE "issues"."origin_kind" = 'routine_execution'
-- and "issues"."origin_id" is not null
-- and "issues"."hidden_at" is null
-- and "issues"."execution_run_id" is not null
-- and "issues"."status" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked');
-- -- Restoring the hidden duplicates is operator-driven; identify rows by
-- -- (origin_kind='routine_execution', hidden_at within the migration apply
-- -- window) and decide per-row whether to set hidden_at = NULL.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
-- GLA-1064: Grant `tasks:cancel_routine_execution` to OpsReaper at the Gladia
-- company (`050de589`). The route bypass added in this change allows an agent
-- holding this permission to PATCH status=cancelled on a routine_execution
-- issue assigned to a different agent (Spec §11.a). Without the grant the
-- bypass is inert.
--
-- INSERT is conditional: skip silently when the company or the agent does not
-- exist on this instance (other deployments). The unique index on
-- (company_id, principal_type, principal_id, permission_key) makes the insert
-- idempotent across re-runs via ON CONFLICT DO NOTHING.

INSERT INTO "principal_permission_grants" (
"company_id",
"principal_type",
"principal_id",
"permission_key",
"scope",
"granted_by_user_id",
"created_at",
"updated_at"
)
SELECT
'050de589-23d3-40bb-b227-efea13164d01'::uuid,
'agent',
'16d0232f-1249-4db9-82d6-ab237c926e59',
'tasks:cancel_routine_execution',
NULL,
NULL,
now(),
now()
WHERE EXISTS (
SELECT 1 FROM "companies" WHERE "id" = '050de589-23d3-40bb-b227-efea13164d01'::uuid
)
AND EXISTS (
SELECT 1 FROM "agents" WHERE "id" = '16d0232f-1249-4db9-82d6-ab237c926e59'::uuid
)
ON CONFLICT ("company_id", "principal_type", "principal_id", "permission_key") DO NOTHING;

-- Rollback (manual; not a drizzle automatic down):
-- DELETE FROM "principal_permission_grants"
-- WHERE "company_id" = '050de589-23d3-40bb-b227-efea13164d01'::uuid
-- AND "principal_type" = 'agent'
-- AND "principal_id" = '16d0232f-1249-4db9-82d6-ab237c926e59'
-- AND "permission_key" = 'tasks:cancel_routine_execution';
39 changes: 39 additions & 0 deletions packages/db/src/migrations/0084_shared_instructions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- GLA-873 / GLA-1066: company-level shared_instructions schema.
--
-- Adds:
-- * companies.shared_instructions (nullable text) — single canonical policy
-- prepended to every agent's resolved instructions file at heartbeat time.
-- * agents.shared_instructions_opt_out (boolean, default false) — per-agent
-- escape hatch (board-only write).
-- * company_shared_instructions_history — append-only audit log of every
-- write to companies.shared_instructions, with previous + new value, actor,
-- timestamp, and optional request id.
--
-- Rollback (manual; not a drizzle automatic down):
-- ALTER TABLE "companies" DROP COLUMN IF EXISTS "shared_instructions";
-- ALTER TABLE "agents" DROP COLUMN IF EXISTS "shared_instructions_opt_out";
-- DROP TABLE IF EXISTS "company_shared_instructions_history";

ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "shared_instructions" text;
--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN IF NOT EXISTS "shared_instructions_opt_out" boolean DEFAULT false NOT NULL;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "company_shared_instructions_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"actor_user_id" text,
"actor_kind" text NOT NULL,
"actor_ip_or_source" text,
"previous_value" text,
"new_value" text,
"diff_summary" text,
"request_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_shared_instructions_history_company_id_companies_id_fk') THEN
ALTER TABLE "company_shared_instructions_history" ADD CONSTRAINT "company_shared_instructions_history_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_shared_instructions_history_company_created_at_idx" ON "company_shared_instructions_history" USING btree ("company_id","created_at");
21 changes: 21 additions & 0 deletions packages/db/src/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,27 @@
"when": 1778067785040,
"tag": "0081_optimal_dormammu",
"breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1778614182000,
"tag": "0082_routine_execution_uniqueness_pre_execution_run",
"breakpoints": true
},
{
"idx": 83,
"version": "7",
"when": 1778688000000,
"tag": "0083_ops_reaper_cancel_routine_execution_grant",
"breakpoints": true
},
{
"idx": 84,
"version": "7",
"when": 1778774400000,
"tag": "0084_shared_instructions",
"breakpoints": true
}
]
}
2 changes: 2 additions & 0 deletions packages/db/src/schema/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
integer,
timestamp,
jsonb,
boolean,
index,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
Expand All @@ -32,6 +33,7 @@ export const agents = pgTable(
pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }),
permissions: jsonb("permissions").$type<Record<string, unknown>>().notNull().default({}),
sharedInstructionsOptOut: boolean("shared_instructions_opt_out").notNull().default(false),
lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/schema/companies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const companies = pgTable(
feedbackDataSharingConsentByUserId: text("feedback_data_sharing_consent_by_user_id"),
feedbackDataSharingTermsVersion: text("feedback_data_sharing_terms_version"),
brandColor: text("brand_color"),
sharedInstructions: text("shared_instructions"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
Expand Down
26 changes: 26 additions & 0 deletions packages/db/src/schema/company_shared_instructions_history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";

export const companySharedInstructionsHistory = pgTable(
"company_shared_instructions_history",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
actorUserId: text("actor_user_id"),
actorKind: text("actor_kind").notNull(),
actorIpOrSource: text("actor_ip_or_source"),
previousValue: text("previous_value"),
newValue: text("new_value"),
diffSummary: text("diff_summary"),
requestId: text("request_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyCreatedAtIdx: index("company_shared_instructions_history_company_created_at_idx").on(
table.companyId,
table.createdAt,
),
}),
);
1 change: 1 addition & 0 deletions packages/db/src/schema/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { companies } from "./companies.js";
export { companySharedInstructionsHistory } from "./company_shared_instructions_history.js";
export { companyLogos } from "./company_logos.js";
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
export { instanceSettings } from "./instance_settings.js";
Expand Down
5 changes: 4 additions & 1 deletion packages/db/src/schema/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,13 @@ export const issues = pgTable(
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
.on(table.companyId, table.originKind, table.originId, table.originFingerprint)
.where(
// Predicate intentionally excludes executionRunId — siblings must collide
// at INSERT time, before the heartbeat dispatcher populates executionRunId.
// Otherwise a later UPDATE that admitted both rows into the index would
// wedge every subsequent write (including the reaper). See GLA-291.
sql`${table.originKind} = 'routine_execution'
and ${table.originId} is not null
and ${table.hiddenAt} is null
and ${table.executionRunId} is not null
and ${table.status} in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')`,
),
activeLivenessRecoveryIncidentIdx: uniqueIndex("issues_active_liveness_recovery_incident_uq")
Expand Down
3 changes: 3 additions & 0 deletions packages/plugins/plugin-github-sync/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist
node_modules
.paperclip-sdk
30 changes: 30 additions & 0 deletions packages/plugins/plugin-github-sync/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# @paperclipai/plugin-github-sync

## 0.1.0

### Initial release — one-way Paperclip → GitHub issue mirror

**Features**

- Mirrors Paperclip issues to a GitHub repository on `issue.created` and `issue.updated` events.
- Goal-subtree filtering: `syncedGoalIds` accepts UUID or short-prefix strings; an issue is in scope when its goal or any ancestor goal matches. Empty array syncs all goals.
- Status mapping: `done` → `closed/completed`, `cancelled` → `closed/not_planned`, all other statuses → `open`.
- Body sanitisation: internal Paperclip links (`[label](/PREFIX/issues/…)`) are rewritten to plain text; bare UUID v4 strings are replaced with `[id-redacted]` before pushing to GitHub.
- Title format: `[GLA-NN] Issue title` when an identifier is present.
- `paperclip-synced` label applied to every mirrored issue.
- Outbound-only sync footer appended to every body: informs GitHub readers that comments are not read back.
- Issue-number mapping persisted in `plugin_state` (`scope: issue`, `namespace: github-sync`, `key: gh-issue-number`) — re-enabled integrations update existing GitHub issues rather than re-creating them.
- Goal-subtree cache: 5-minute TTL per company, invalidated on `goal.updated`.
- Rate-limit handling: backs off on HTTP 429 and 403+`x-ratelimit-remaining: 0` with up to 3 retries and exponential back-off.
- `dryRun` mode (default `true`): logs sync actions without calling GitHub — disable once ready to go live.
- Admin UI in Company Settings → GitHub Sync (repo, host, PAT/secret, synced goals, dry-run toggle).
- Manual sync endpoint: `POST /api/issues/{issueId}/sync-to-github`.
- GitHub Enterprise Server supported via the `host` field.

**Security**

- PAT stored as a Paperclip secret; never returned by the API, never logged.
- Token redacted from all error messages before surfacing.
- Host-pinning on every outbound request — constructed URL host is validated against the configured host before each call.
- `redirect: "error"` on all fetch calls — no redirect-following.
- No inbound webhook route; plugin has zero surface area for GitHub → Paperclip traffic.
Loading
Loading