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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ jobs:
# node:test suites in scripts/ run in ci-scope and library instead —
# see scripts/vite.config.mts for the split.
- run: npx nx test scripts
- run: npx nx run-many -t lint test --projects=marketing-assets,marketing-channels --parallel=2

library:
name: Library — lint / test / build
Expand Down Expand Up @@ -267,6 +268,9 @@ jobs:
cache: npm
- run: npm ci
- run: npx nx lint lifecycle
- run: npx nx lint growth-capture
- run: npx nx test growth-capture
- run: npx nx run growth-capture:check
- run: npx nx test lifecycle
- run: npx nx run lifecycle:check
- run: npx nx build lifecycle
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ cockpit/**/angular/test-results/
/test-results/

# Marketing channel dry-run simulation outputs (ephemeral; see "simulatedAt")
marketing/channels/marketing/cowork/outbox/dry-runs/

# Env
.env
Expand Down
7 changes: 5 additions & 2 deletions apps/growth-research/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"engines": { "node": "24" },
"engines": {
"node": "24"
},
"dependencies": {
"@dawn-ai/cli": "0.8.26",
"@dawn-ai/core": "0.8.26",
Expand All @@ -22,6 +24,7 @@
"devDependencies": {
"@dawn-ai/evals": "0.8.26",
"@dawn-ai/testing": "0.8.26",
"@dawn-ai/workspace": "0.8.26"
"@dawn-ai/workspace": "0.8.26",
"@threadplane-internal/growth-capture": "0.0.0"
}
}
2 changes: 1 addition & 1 deletion apps/growth-research/scripts/package-langsmith.mts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ async function copySource(root: string, path: string, output: string): Promise<v
const name = basename(path);
const local = relative(root, path);
if (local.startsWith('src/production/') && !['contracts.ts', 'entry.ts', 'executor.ts', 'claims.ts', 'telemetry.ts', 'tracing.ts'].includes(name)) throw new Error(`Unexpected production source: ${local}`);
if (local.startsWith('src/pilot/') && !['context.ts', 'contracts.ts', 'validation.ts'].includes(name)) return;
if (local === 'src/pilot' || local.startsWith('src/pilot/')) return;
if (name.startsWith('.') || name === 'node_modules' || /\.(spec|test)\.[cm]?ts$/.test(name)) return;
if ((await lstat(path)).isDirectory()) {
await mkdir(output, { recursive: true });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readEvidence } from '../../../../pilot/context.js';
import { readEvidence } from '../../../../company/context.js';
/** List sources when sourceId is omitted; otherwise read a captured source with copy-ready citationOptions. Copy each citation object separately without joining quotes. */
export default async function tool(input: { sourceId?: string }) {
return readEvidence(input);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { submitCandidate, getPilotContext } from '../../../../pilot/context.js';
import { invalidCitations } from '../../../../pilot/validation.js';
import { CandidateSchema } from '../../../../pilot/contracts.js';
import { submitCandidate, getPilotContext } from '../../../../company/context.js';
import { invalidCitations } from '../../../../company/validation.js';
import { CandidateSchema } from '../../../../company/contracts.js';

// Dawn's supported authored schema export preserves nullable fields and the
// exact same bounds used by deterministic submission validation.
Expand Down
4 changes: 2 additions & 2 deletions apps/growth-research/src/pilot/acquisition.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {
createCompanyCapture,
type CompanyCaptureDiagnostic,
} from '../../../lifecycle/src/enrichment/company-capture.js';
import type { CompanyPageEvidence } from '../../../lifecycle/src/enrichment/schema.js';
type CompanyPageEvidence,
} from '@threadplane-internal/growth-capture';

const expectedPaths = ['/'];
export async function acquireCompanies(
Expand Down
6 changes: 3 additions & 3 deletions apps/growth-research/src/pilot/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import type {
PilotCase,
Validation,
SubmissionAttempt,
} from './contracts.js';
} from '../company/contracts.js';
import {
createPilotContext,
withPilotContext,
PilotStop,
pilotLimits,
drainPilotOperations,
} from './context.js';
import { validateCandidate } from './validation.js';
} from '../company/context.js';
import { validateCandidate } from '../company/validation.js';

export interface AgentResult {
attempts?: SubmissionAttempt[];
Expand Down
4 changes: 2 additions & 2 deletions apps/growth-research/src/pilot/corpus.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';
import { CorpusSchema, type Corpus, type PilotCase } from './contracts.js';
export { sourceIds } from './contracts.js';
import { CorpusSchema, type Corpus, type PilotCase } from '../company/contracts.js';
export { sourceIds } from '../company/contracts.js';
export const evidenceHash = (page: { facts: string[]; snippets: string[] }) =>
createHash('sha256')
.update(JSON.stringify({ facts: page.facts, snippets: page.snippets }))
Expand Down
2 changes: 1 addition & 1 deletion apps/growth-research/src/pilot/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { evidenceHash } from './corpus.js';
import type { Corpus, PilotCase } from './contracts.js';
import type { Corpus, PilotCase } from '../company/contracts.js';
function fixture(
id: string,
facts: string[],
Expand Down
2 changes: 1 addition & 1 deletion apps/growth-research/src/pilot/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { validateCorpus, corpusHash } from './corpus.js';
import type { runAgent } from './agent-runner.js';
import { writeRecord, createReviewPacket } from './reports.js';
import type { PilotCase } from './contracts.js';
import type { PilotCase } from '../company/contracts.js';

type Options = {
root: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/growth-research/src/production/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';
import { z } from 'zod';
import { CandidateSchema, PageSchema } from '../pilot/contracts.js';
import { CandidateSchema, PageSchema } from '../company/contracts.js';

export const productionGraphId = 'growth_company';
export const requestMaxAgeMs = 120_000;
Expand Down
4 changes: 2 additions & 2 deletions apps/growth-research/src/production/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
drainPilotOperations,
PilotStop,
withPilotContext,
} from '../pilot/context.js';
import { validateCandidate } from '../pilot/validation.js';
} from '../company/context.js';
import { validateCandidate } from '../company/validation.js';
import type { ClaimStore } from './claims.js';
import {
CompanyResultSchema,
Expand Down
2 changes: 1 addition & 1 deletion apps/growth-research/src/production/telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PilotEvent } from '../pilot/context.js';
import type { PilotEvent } from '../company/context.js';
/** Deliberately no prompt, page, candidate, identity, error object or credentials. */
export interface CompanyTelemetry {
attemptId: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/growth-research/src/production/tracing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import type { PilotEvent } from '../pilot/context.js';
import type { PilotEvent } from '../company/context.js';
import type { TelemetrySink } from './telemetry.js';

const EventSchema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion apps/growth-research/src/runtime/model-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
recordRejectedSubmission,
trackPilotOperation,
type PilotEvent,
} from '../pilot/context.js';
} from '../company/context.js';

export const providerLimits = {
maxTokens: 1024,
Expand Down
2 changes: 1 addition & 1 deletion apps/growth-research/test/model-boundary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createServer, type RequestListener, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, expect, it, vi } from 'vitest';
import { BoundedChatOpenAI } from '../src/runtime/model-boundary.js';
import { createPilotContext, withPilotContext } from '../src/pilot/context.js';
import { createPilotContext, withPilotContext } from '../src/company/context.js';
import { syntheticCorpus } from '../src/pilot/fixtures.js';

let server: Server | undefined;
Expand Down
18 changes: 17 additions & 1 deletion apps/growth-research/test/packaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('standalone LangSmith packaging', () => {
const config = JSON.parse(await readFile(path, 'utf8'));
config.graphs['/enrichment/company-pilot#agent'] = './.dawn/build/enrichment-company-pilot.ts:graph';
await writeFile(path, JSON.stringify(config));
for (const file of ['.dawn/build/enrichment-company-pilot.ts', 'src/app/enrichment/company-pilot/index.ts', 'src/production/entry.ts', 'src/pilot/baseline.ts']) {
for (const file of ['.dawn/build/enrichment-company-pilot.ts', 'src/app/enrichment/company-pilot/index.ts', 'src/production/entry.ts', 'src/company/context.ts', 'src/company/contracts.ts', 'src/company/validation.ts', 'src/pilot/baseline.ts', 'src/pilot/acquisition.ts']) {
await mkdir(dirname(join(root, file)), { recursive: true });
await writeFile(join(root, file), 'export const privatePilot = true;');
}
Expand All @@ -59,13 +59,29 @@ describe('standalone LangSmith packaging', () => {
expect(await readdir(join(output, 'src/app/enrichment'))).toEqual(['company-pilot', 'research']);
expect(JSON.parse(await readFile(join(output, 'langgraph.json'), 'utf8')).graphs).toEqual({ growth_research: graphEntry, growth_company: './src/production/entry.ts:graph' });
await expect(readFile(join(output, 'src/pilot/baseline.ts'))).rejects.toThrow();
await expect(readFile(join(output, 'src/pilot/acquisition.ts'))).rejects.toThrow();
expect(await readdir(join(output, 'src/company'))).toEqual(['context.ts', 'contracts.ts', 'validation.ts']);
expect(JSON.parse(await readFile(join(output, 'package.json'), 'utf8'))).not.toHaveProperty('devDependencies');
});
it('normalizes Node 22 to 24 and clears environment file configuration', async () => {
const output = await stageLangSmith(await fixture());
const config = JSON.parse(await readFile(join(output, 'langgraph.json'), 'utf8'));
expect(config).toEqual({ graphs: { [publicGraphId]: graphEntry }, env: {}, node_version: '24', api_version: '0.13.4', dependencies: ['.'] });
});

it('omits the local capture development dependency from the standalone manifest', async () => {
const root = await fixture();
const path = join(root, 'package.json');
const manifest = JSON.parse(await readFile(path, 'utf8'));
manifest.devDependencies = { '@threadplane-internal/growth-capture': '0.0.0' };
await writeFile(path, JSON.stringify(manifest));
const output = await stageLangSmith(root);
expect(JSON.parse(await readFile(join(output, 'package.json'), 'utf8'))).toEqual({
name: 'fixture', version: '0.0.0', private: true, type: 'module',
engines: { node: '24' }, dependencies: { '@dawn-ai/core': '0.8.26' },
});
});

it('accepts the explicit pinned Agent Server API version', async () => {
const root = await fixture();
const path = join(root, '.dawn/build/langgraph.json');
Expand Down
3 changes: 1 addition & 2 deletions apps/growth-research/test/pilot-acquisition.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { afterEach, expect, it, vi } from 'vitest';
import { acquireCompanies } from '../src/pilot/acquisition.js';
// Exercise the same internal capture dependency used by pilot acquisition.
// eslint-disable-next-line @nx/enforce-module-boundaries
import * as firecrawl from '../../lifecycle/src/enrichment/firecrawl.js';
import * as firecrawl from '@threadplane-internal/growth-capture/firecrawl';

afterEach(() => {
vi.restoreAllMocks();
Expand Down
4 changes: 2 additions & 2 deletions apps/growth-research/test/pilot-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
withPilotContext,
submitCandidate,
getPilotContext,
} from '../src/pilot/context.js';
} from '../src/company/context.js';
import { syntheticCorpus } from '../src/pilot/fixtures.js';
import { runAgent } from '../src/pilot/agent-runner.js';
let sharedMock:
Expand Down Expand Up @@ -397,7 +397,7 @@ it('authorizes production contexts only under the independent managed gate', asy
deadline: 123,
});
expect(context.deadline).toBe(123);
const { assertPilotContext } = await import('../src/pilot/context.js');
const { assertPilotContext } = await import('../src/company/context.js');
await withPilotContext(context, async () => {
expect(() => assertPilotContext()).toThrow(/pilot_mode_required/);
vi.stubEnv('GROWTH_RESEARCH_PRODUCTION_MODE', 'managed-company-only');
Expand Down
4 changes: 2 additions & 2 deletions apps/growth-research/test/pilot-core.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { syntheticCorpus } from '../src/pilot/fixtures.js';
import { validateCorpus, corpusHash } from '../src/pilot/corpus.js';
import { validateCandidate } from '../src/pilot/validation.js';
import { validateCandidate } from '../src/company/validation.js';
import {
createPilotContext,
withPilotContext,
readEvidence,
submitCandidate,
countModelRequest,
} from '../src/pilot/context.js';
} from '../src/company/context.js';
const candidate = {
profile: { name: 'Atlas Synthetic', description: null, industry: null },
unknowns: ['description', 'industry'],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, expect, it, vi } from 'vitest';
import tool from '../src/app/enrichment/company-pilot/tools/submitCandidate.js';
import { createPilotContext, withPilotContext } from '../src/pilot/context.js';
import { createPilotContext, withPilotContext } from '../src/company/context.js';
import { syntheticCorpus } from '../src/pilot/fixtures.js';
afterEach(() => vi.unstubAllEnvs());
it('locates bad citations for repair while retaining the unchanged validation contract', async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/growth-research/test/pilot-telemetry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
readEvidence,
submitCandidate,
recordRejectedSubmission,
} from '../src/pilot/context.js';
} from '../src/company/context.js';
import { syntheticCorpus } from '../src/pilot/fixtures.js';

afterEach(() => vi.unstubAllEnvs());
Expand Down
2 changes: 1 addition & 1 deletion apps/growth-research/test/production.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
submitCandidate,
trackPilotOperation,
countModelRequest,
} from '../src/pilot/context.js';
} from '../src/company/context.js';
import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons';
import { AsyncLocalStorage } from 'node:async_hooks';
import { RunTree } from 'langsmith/run_trees';
Expand Down
2 changes: 2 additions & 0 deletions apps/lifecycle/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Threadplane lifecycle service

Part of [Growth architecture and operations](../../docs/growth/README.md).

This Node 24 service builds Dawn 0.8.26's native Vercel target and adds a thin app-owned service boundary. The Vercel adapter requires the exact `LIFECYCLE_SERVICE_SECRET` bearer token on every Dawn path. Dawn route middleware repeats the same check for execution routes.

The service has two database boundaries:
Expand Down
4 changes: 2 additions & 2 deletions apps/lifecycle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
"@react-email/render": "2.1.0",
"@threadplane-internal/growth": "0.0.0",
"hono": "4.13.5",
"parse5": "8.0.1",
"resend": "6.10.0",
"zod": "4.4.3"
"zod": "4.4.3",
"@threadplane-internal/growth-capture": "0.0.0"
}
}
67 changes: 4 additions & 63 deletions apps/lifecycle/src/email-keyring.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,8 @@
import type { EmailHmacKey, EmailHmacKeyring } from './growth.js';
import { parseEmailHmacKeyring } from './growth.js';

function version(value: string | undefined): number {
if (!value || !/^\d+$/u.test(value)) {
throw new Error('Growth email HMAC active version is required');
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 32_767) {
throw new Error('Growth email HMAC active version is invalid');
}
return parsed;
}

function previousKey(candidate: unknown): EmailHmacKey {
if (
candidate === null ||
typeof candidate !== 'object' ||
Array.isArray(candidate)
) {
throw new Error('Growth email HMAC previous key is invalid');
}
const record = candidate as Record<string, unknown>;
if (
Object.keys(record).length !== 2 ||
typeof record['version'] !== 'number' ||
!Number.isSafeInteger(record['version']) ||
record['version'] < 1 ||
record['version'] > 32_767 ||
typeof record['secret'] !== 'string' ||
Buffer.byteLength(record['secret'], 'utf8') < 32
)
throw new Error('Growth email HMAC previous key is invalid');
return { version: record['version'], secret: record['secret'] };
}

// Keep lifecycle's server-only configuration aligned with collection's keyring.
// Load lazily: disabled activation must not require identity credentials.
// Load lazily so disabled activation does not require identity credentials.
export function loadEmailHmacKeyring(
environment: Readonly<Record<string, string | undefined>> = process.env
): EmailHmacKeyring {
const secret = environment['GROWTH_EMAIL_HMAC_ACTIVE_SECRET'];
if (!secret || Buffer.byteLength(secret, 'utf8') < 32) {
throw new Error('Growth email HMAC active secret is required');
}
const active = {
version: version(environment['GROWTH_EMAIL_HMAC_ACTIVE_VERSION']),
secret,
};
const rawPrevious = environment['GROWTH_EMAIL_HMAC_PREVIOUS_KEYS'];
if (!rawPrevious) return { active };
let parsed: unknown;
try {
parsed = JSON.parse(rawPrevious) as unknown;
} catch {
throw new Error('Growth email HMAC previous keys are invalid');
}
if (!Array.isArray(parsed))
throw new Error('Growth email HMAC previous keys must be an array');
const previous = parsed.map(previousKey);
const versions = new Set([active.version]);
for (const key of previous) {
if (versions.has(key.version))
throw new Error('Growth email HMAC key versions must be unique');
versions.add(key.version);
}
return { active, previous };
) {
return parseEmailHmacKeyring(environment);
}
2 changes: 1 addition & 1 deletion apps/lifecycle/src/enrichment/dawn-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type GrowthAppJobHandler,
type SqlExecutor,
} from '../growth.js';
import { createCompanyCapture } from './company-capture.js';
import { createCompanyCapture } from '@threadplane-internal/growth-capture';
import {
createDawnResearchClient,
type DawnResearchClient,
Expand Down
2 changes: 1 addition & 1 deletion apps/lifecycle/src/enrichment/dawn-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
hashCompanyEvidence,
} from '../../../growth-research/src/production/contracts.js';
// eslint-disable-next-line @nx/enforce-module-boundaries -- use the managed candidate validator at publication
import { validateCandidate } from '../../../growth-research/src/pilot/validation.js';
import { validateCandidate } from '../../../growth-research/src/company/validation.js';

/** Revalidate the remote candidate against the original persisted snapshot. */
export function companyArtifact(
Expand Down
Loading
Loading