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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md) if [ -f \"$CLAUDE_PROJECT_DIR/package.json\" ]; then cd \"$CLAUDE_PROJECT_DIR\" && pnpm exec prettier --write \"$f\" >/dev/null 2>&1; fi ;; esac; exit 0"
},
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.ts|*.tsx) if [ -f \"$CLAUDE_PROJECT_DIR/package.json\" ]; then cd \"$CLAUDE_PROJECT_DIR\" && pnpm exec eslint --fix \"$f\" >/dev/null 2>&1; fi ;; esac; exit 0"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "if [ -f \"$CLAUDE_PROJECT_DIR/package.json\" ]; then cd \"$CLAUDE_PROJECT_DIR\" && pnpm exec tsc --noEmit --pretty false 2>&1 | tail -20; fi; exit 0"
}
]
}
]
}
}
22 changes: 20 additions & 2 deletions apps/web/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { createNotifier, createSynthesisClient, createTriageClient } from '@flank/pipeline';
import {
createNotifier,
createOkfPublisher,
createSynthesisClient,
createTriageClient,
parseOkfTargets,
} from '@flank/pipeline';
import {
createDeliverySweepFunction,
createNightlySynthesisFunction,
createOkfDeliveryFunction,
createScheduledTickFunction,
inngest,
type DeliveryRuntime,
type OkfDeliveryRuntime,
type SchedulerRuntime,
type SynthesisRuntime,
} from '@flank/pipeline/inngest';
Expand All @@ -31,11 +39,21 @@ const buildDeliveryRuntime = async (): Promise<DeliveryRuntime> => ({
notifier: createNotifier(process.env),
});

const buildOkfDeliveryRuntime = async (): Promise<OkfDeliveryRuntime> => ({
store: store(),
// Real GitHub publisher when FLANK_OKF_GITHUB_TOKEN is set; targets come from FLANK_OKF_TARGETS
// (JSON). With neither configured the sweep has no targets and no-ops.
publisher: createOkfPublisher(process.env),
targets: parseOkfTargets(process.env.FLANK_OKF_TARGETS),
baseUrl: process.env.FLANK_APP_ORIGIN ?? 'https://app.flank.example',
});

const scheduledTick = createScheduledTickFunction(buildSchedulerRuntime);
const nightlySynthesis = createNightlySynthesisFunction(buildSynthesisRuntime);
const deliverySweep = createDeliverySweepFunction(buildDeliveryRuntime);
const okfDelivery = createOkfDeliveryFunction(buildOkfDeliveryRuntime);

export const { GET, POST, PUT } = serve({
client: inngest,
functions: [scheduledTick, nightlySynthesis, deliverySweep],
functions: [scheduledTick, nightlySynthesis, deliverySweep, okfDelivery],
});
44 changes: 44 additions & 0 deletions apps/web/app/api/okf/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { bundleToTar, loadWorkspaceExport, projectWorkspaceBundle } from '@flank/okf-export';
import { NextResponse, type NextRequest } from 'next/server';
import { resolveActiveWorkspace } from '../../../lib/auth/session';
import { getStore } from '../../../lib/store';

/**
* GET /api/okf — the workspace's competitive knowledge as an OKF bundle.
* Default response is a deterministic ustar tarball; `?format=json` returns
* `{ files, findings }` for programmatic consumers.
*
* Fail closed (projection-only rule): any `error` finding — an unverified or
* unresolvable citation, a structural bundle defect — returns 409 with the
* findings instead of a bundle. Unproven provenance never ships.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
const active = await resolveActiveWorkspace();
const workspaceName =
active.memberships.find((m) => m.workspace.id === active.workspaceId)?.workspace.name ??
active.workspaceId;

const input = await loadWorkspaceExport(
getStore(),
{ id: active.workspaceId, name: workspaceName },
request.nextUrl.origin,
);
const { files, findings } = projectWorkspaceBundle(input);

if (findings.some((finding) => finding.severity === 'error')) {
return NextResponse.json({ error: 'bundle failed the publish gate', findings }, { status: 409 });
}

if (request.nextUrl.searchParams.get('format') === 'json') {
return NextResponse.json({ files: Object.fromEntries(files), findings });
}

return new NextResponse(Buffer.from(bundleToTar(files)), {
headers: {
'Content-Type': 'application/x-tar',
'Content-Disposition': `attachment; filename="flank-okf-${active.workspaceId}.tar"`,
// The bundle reflects live published state; never cache across sessions.
'Cache-Control': 'private, no-store',
},
});
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@flank/core": "workspace:*",
"@flank/db": "workspace:*",
"@flank/okf-export": "workspace:*",
"@flank/pipeline": "workspace:*",
"inngest": "^4.11.0",
"next": "^16.2.10",
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,37 @@ export interface Alert {
readonly deliveredAt: Date | null;
}

// --- OKF bundle delivery (M2) ---

/**
* The outcome of one attempt to push a workspace's OKF bundle to its designated git repo.
* `published` shipped a commit; `failed` is a recorded attempt that errored (publish I/O, or a
* gate that refused the bundle). A run that finds no changes ships nothing and records no row —
* silence isn't a delivery. Append-only (Invariant 5).
*/
export const BUNDLE_DELIVERY_STATUSES = ['published', 'failed'] as const;
export type BundleDeliveryStatus = (typeof BUNDLE_DELIVERY_STATUSES)[number];

export interface BundleDelivery {
readonly id: string;
readonly workspaceId: string;
readonly status: BundleDeliveryStatus;
readonly commitSha: string | null;
/** The git ref the commit landed on (e.g. `refs/heads/flank-okf`); null on a failed attempt. */
readonly branchRef: string | null;
readonly pullRequestUrl: string | null;
/**
* path → sha256 content hash of the bundle actually delivered — the baseline the next run diffs
* against to decide what changed. Empty for a failed attempt (nothing shipped).
*/
readonly manifest: Readonly<Record<string, string>>;
readonly filesAdded: number;
readonly filesModified: number;
readonly filesRemoved: number;
readonly error: string | null;
readonly createdAt: Date;
}

/** Boundary validation for a channel config arriving from settings/UI — never trust raw input. */
export const AlertChannelConfigSchema = z
.object({
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AppUser,
BattlecardSection,
BattlecardSectionKind,
BundleDelivery,
Claim,
Competitor,
CoverageRun,
Expand Down Expand Up @@ -349,6 +350,16 @@ export interface FlankStore {
/** A workspace's delivery log, newest first (request-safe, for the /authed/alerts view). */
listAlertsForWorkspace(workspaceId: string): Promise<readonly Alert[]>;

// --- OKF bundle delivery (M2) ---

/** Append-only record of one git-push attempt (Invariant 5). Workspace must exist. */
insertBundleDelivery(delivery: BundleDelivery): Promise<BundleDelivery>;
/**
* The most recent successfully-published delivery for a workspace, or null if none. Its manifest
* is the baseline the next delivery run diffs against; failed attempts never become the baseline.
*/
latestPublishedBundleDelivery(workspaceId: string): Promise<BundleDelivery | null>;

/**
* Run `fn` as a single atomic unit of work. The handle passed to `fn` is a {@link FlankStore}
* bound to the transaction; if `fn` throws, every write performed through that handle is rolled
Expand Down
27 changes: 27 additions & 0 deletions packages/db/drizzle/0007_okf_deliveries.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- M2 OKF bundle delivery. Append-only record of each git-push attempt: `published` shipped a commit,
-- `failed` errored (publish I/O or a refused bundle). `manifest` (path -> sha256) is the baseline the
-- next run diffs against. Append-only in depth like the history tables (0002): the reject-mutation
-- trigger blocks any UPDATE/DELETE against raw SQL, not just the app layer.

CREATE TYPE "bundle_delivery_status" AS ENUM('published', 'failed');--> statement-breakpoint

CREATE TABLE "okf_delivery" (
"id" text PRIMARY KEY NOT NULL,
"workspace_id" text NOT NULL,
"status" "bundle_delivery_status" NOT NULL,
"commit_sha" text,
"branch_ref" text,
"pull_request_url" text,
"manifest" jsonb DEFAULT '{}'::jsonb NOT NULL,
"files_added" integer DEFAULT 0 NOT NULL,
"files_modified" integer DEFAULT 0 NOT NULL,
"files_removed" integer DEFAULT 0 NOT NULL,
"error" text,
"created_at" timestamp with time zone NOT NULL
);--> statement-breakpoint

ALTER TABLE "okf_delivery" ADD CONSTRAINT "okf_delivery_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "okf_delivery_workspace_status_idx" ON "okf_delivery" USING btree ("workspace_id","status");--> statement-breakpoint

-- Append-only in depth: reuse flank_reject_mutation() (defined in 0002) to block UPDATE and DELETE.
CREATE TRIGGER okf_delivery_append_only BEFORE UPDATE OR DELETE ON "okf_delivery" FOR EACH ROW EXECUTE FUNCTION flank_reject_mutation();
18 changes: 18 additions & 0 deletions packages/db/src/drizzle-mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
AlertChannelConfig,
AppUser,
BattlecardSection,
BundleDelivery,
Claim,
Competitor,
CoverageRun,
Expand All @@ -24,6 +25,7 @@ import type {
deltas,
dossierSections,
memberships,
okfDeliveries,
snapshots,
sources,
workspaces,
Expand Down Expand Up @@ -193,6 +195,22 @@ export const toAlert = (row: typeof alerts.$inferSelect): Alert =>
deliveredAt: row.deliveredAt,
});

export const toBundleDelivery = (row: typeof okfDeliveries.$inferSelect): BundleDelivery =>
Object.freeze({
id: row.id,
workspaceId: row.workspaceId,
status: row.status,
commitSha: row.commitSha,
branchRef: row.branchRef,
pullRequestUrl: row.pullRequestUrl,
manifest: row.manifest,
filesAdded: row.filesAdded,
filesModified: row.filesModified,
filesRemoved: row.filesRemoved,
error: row.error,
createdAt: row.createdAt,
});

const PG_UNIQUE_VIOLATION = '23505';

/**
Expand Down
51 changes: 51 additions & 0 deletions packages/db/src/drizzle-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type AppUser,
type BattlecardSection,
type BattlecardSectionKind,
type BundleDelivery,
type Claim,
type Competitor,
type CoverageRun,
Expand Down Expand Up @@ -42,6 +43,7 @@ import {
deltas,
dossierSections,
memberships,
okfDeliveries,
snapshots,
sources,
workspaces,
Expand All @@ -51,6 +53,7 @@ import {
isUniqueViolation,
toAppUser,
toBattlecardSection,
toBundleDelivery,
toClaim,
toCompetitor,
toCoverageRun,
Expand Down Expand Up @@ -788,6 +791,54 @@ export class DrizzleFlankStore implements FlankStore {
return alertStore.listAlertsForWorkspace(this.db, workspaceId);
}

// --- OKF bundle delivery (M2) ---

async insertBundleDelivery(delivery: BundleDelivery): Promise<BundleDelivery> {
const parent = await this.db
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.id, delivery.workspaceId))
.limit(1);
if (parent[0] === undefined) {
throw new UnknownEntityError(`workspace ${delivery.workspaceId} does not exist`);
}
return this.insertOne(
() =>
this.db
.insert(okfDeliveries)
.values({
id: delivery.id,
workspaceId: delivery.workspaceId,
status: delivery.status,
commitSha: delivery.commitSha,
branchRef: delivery.branchRef,
pullRequestUrl: delivery.pullRequestUrl,
manifest: delivery.manifest,
filesAdded: delivery.filesAdded,
filesModified: delivery.filesModified,
filesRemoved: delivery.filesRemoved,
error: delivery.error,
createdAt: delivery.createdAt,
})
.returning(),
toBundleDelivery,
'okf_delivery',
);
}

async latestPublishedBundleDelivery(workspaceId: string): Promise<BundleDelivery | null> {
const rows = await this.db
.select()
.from(okfDeliveries)
.where(
and(eq(okfDeliveries.workspaceId, workspaceId), eq(okfDeliveries.status, 'published')),
)
// Deterministic "latest": createdAt desc, id desc — the memory store's byCreatedThenId mirror.
.orderBy(desc(okfDeliveries.createdAt), desc(okfDeliveries.id))
.limit(1);
return rows[0] === undefined ? null : toBundleDelivery(rows[0]);
}

async withTransaction<T>(fn: (tx: FlankStore) => Promise<T>): Promise<T> {
return this.db.transaction((tx) =>
// `tx` is a transaction-bound handle exposing the same query builder used throughout this
Expand Down
4 changes: 4 additions & 0 deletions packages/db/src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
deltaStateEnum,
deltas,
dossierSections,
okfDeliveries,
snapshots,
sourceTypeEnum,
sources,
Expand All @@ -32,6 +33,7 @@ describe('drizzle schema (no live database required)', () => {
battlecardSections,
alerts,
coverageRuns,
okfDeliveries,
];

// Act
Expand All @@ -49,6 +51,7 @@ describe('drizzle schema (no live database required)', () => {
'battlecard_section',
'alert',
'coverage_run',
'okf_delivery',
]);
});

Expand All @@ -70,6 +73,7 @@ describe('drizzle schema (no live database required)', () => {
'claim',
'dossier_section',
'battlecard_section',
'okf_delivery',
]);
});

Expand Down
Loading
Loading