Orchestrate commercial demand before market frontier

This commit is contained in:
DCCONSTRUCTIONS 2026-07-05 12:36:29 +03:00
parent e542e212b0
commit 4ff150b1e5
3 changed files with 415 additions and 8 deletions

View File

@ -1133,12 +1133,27 @@ export type KeywordAnalysisWorkflowContract = {
generatedAt: string; generatedAt: string;
status: "blocked" | "completed" | "failed" | "queued" | "running" | "waiting"; status: "blocked" | "completed" | "failed" | "queued" | "running" | "waiting";
statusLabel: string; statusLabel: string;
activeStepId: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis" | null; activeStepId:
| "business_synthesis"
| "commercial_demand"
| "demand_collection"
| "keyword_cleaning"
| "keyword_proposal"
| "project_context"
| "semantic_basis"
| null;
activeStepLabel: string | null; activeStepLabel: string | null;
summary: string; summary: string;
reason: string | null; reason: string | null;
steps: Array<{ steps: Array<{
id: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis"; id:
| "business_synthesis"
| "commercial_demand"
| "demand_collection"
| "keyword_cleaning"
| "keyword_proposal"
| "project_context"
| "semantic_basis";
label: string; label: string;
status: "blocked" | "completed" | "failed" | "pending" | "running" | "waiting"; status: "blocked" | "completed" | "failed" | "pending" | "running" | "waiting";
detail: string; detail: string;
@ -1147,8 +1162,12 @@ export type KeywordAnalysisWorkflowContract = {
completedAt: string | null; completedAt: string | null;
}>; }>;
artifacts: { artifacts: {
businessSynthesisRunId: string | null;
commercialDemandRunId: string | null;
keywordCleaningRunId: string | null; keywordCleaningRunId: string | null;
keywordCleaningModelTaskRunId: string | null;
marketGeneratedAt: string | null; marketGeneratedAt: string | null;
marketFrontierRunId: string | null;
modelTaskRunId: string | null; modelTaskRunId: string | null;
wordstatJobId: string | null; wordstatJobId: string | null;
}; };

View File

@ -1,4 +1,6 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { getLatestAlignedSeoBusinessSynthesis } from "../business/seoBusinessSynthesis.js";
import { getLatestAlignedSeoCommercialDemand } from "../commercial/seoCommercialDemand.js";
import { pool } from "../db/client.js"; import { pool } from "../db/client.js";
import { getAnchorReviewContract } from "./anchorReview.js"; import { getAnchorReviewContract } from "./anchorReview.js";
import { getKeywordCleaningByModelTaskRun, getKeywordCleaningContract } from "./keywordCleaning.js"; import { getKeywordCleaningByModelTaskRun, getKeywordCleaningContract } from "./keywordCleaning.js";
@ -12,6 +14,8 @@ type KeywordAnalysisWorkflowStatus = "blocked" | "completed" | "failed" | "queue
type KeywordAnalysisWorkflowStepId = type KeywordAnalysisWorkflowStepId =
| "semantic_basis" | "semantic_basis"
| "project_context" | "project_context"
| "business_synthesis"
| "commercial_demand"
| "demand_collection" | "demand_collection"
| "keyword_cleaning" | "keyword_cleaning"
| "keyword_proposal"; | "keyword_proposal";
@ -41,8 +45,12 @@ export type KeywordAnalysisWorkflowContract = {
reason: string | null; reason: string | null;
steps: KeywordAnalysisWorkflowStep[]; steps: KeywordAnalysisWorkflowStep[];
artifacts: { artifacts: {
businessSynthesisRunId: string | null;
commercialDemandRunId: string | null;
keywordCleaningRunId: string | null; keywordCleaningRunId: string | null;
keywordCleaningModelTaskRunId: string | null;
marketGeneratedAt: string | null; marketGeneratedAt: string | null;
marketFrontierRunId: string | null;
modelTaskRunId: string | null; modelTaskRunId: string | null;
wordstatJobId: string | null; wordstatJobId: string | null;
}; };
@ -73,6 +81,16 @@ const WORKFLOW_STEPS: Array<Pick<KeywordAnalysisWorkflowStep, "detail" | "id" |
id: "project_context", id: "project_context",
label: "Проектный контекст" label: "Проектный контекст"
}, },
{
detail: "Собираем продуктовую рамку: core offer, слои продукта, применения и ограничения расширения.",
id: "business_synthesis",
label: "Бизнес-смысл"
},
{
detail: "Переводим продуктовую рамку в покупательские интенты, внедрение, интеграции и коммерческие формулировки.",
id: "commercial_demand",
label: "Коммерческий спрос"
},
{ {
detail: "Собираем рыночную базу Wordstat по выбранным якорям, коммерческим формулировкам и смежным веткам.", detail: "Собираем рыночную базу Wordstat по выбранным якорям, коммерческим формулировкам и смежным веткам.",
id: "demand_collection", id: "demand_collection",
@ -144,8 +162,12 @@ function buildInitialWorkflow(projectId: string, runId: string): KeywordAnalysis
reason: null, reason: null,
steps: buildInitialSteps(), steps: buildInitialSteps(),
artifacts: { artifacts: {
businessSynthesisRunId: null,
commercialDemandRunId: null,
keywordCleaningRunId: null, keywordCleaningRunId: null,
keywordCleaningModelTaskRunId: null,
marketGeneratedAt: null, marketGeneratedAt: null,
marketFrontierRunId: null,
modelTaskRunId: null, modelTaskRunId: null,
wordstatJobId: null wordstatJobId: null
}, },
@ -153,6 +175,41 @@ function buildInitialWorkflow(projectId: string, runId: string): KeywordAnalysis
}; };
} }
function normalizeWorkflowContract(contract: KeywordAnalysisWorkflowContract): KeywordAnalysisWorkflowContract {
const stepById = new Map(contract.steps.map((step) => [step.id, step]));
return {
...contract,
activeStepLabel: contract.activeStepId ? getStepLabel(contract.activeStepId) : null,
artifacts: {
businessSynthesisRunId: contract.artifacts.businessSynthesisRunId ?? null,
commercialDemandRunId: contract.artifacts.commercialDemandRunId ?? null,
keywordCleaningRunId: contract.artifacts.keywordCleaningRunId ?? null,
keywordCleaningModelTaskRunId: contract.artifacts.keywordCleaningModelTaskRunId ?? null,
marketGeneratedAt: contract.artifacts.marketGeneratedAt ?? null,
marketFrontierRunId: contract.artifacts.marketFrontierRunId ?? null,
modelTaskRunId: contract.artifacts.modelTaskRunId ?? null,
wordstatJobId: contract.artifacts.wordstatJobId ?? null
},
steps: WORKFLOW_STEPS.map((step) => {
const existing = stepById.get(step.id);
return existing
? {
...step,
...existing
}
: {
...step,
completedAt: null,
reason: null,
startedAt: null,
status: "pending" as const
};
})
};
}
function setWorkflowStatus( function setWorkflowStatus(
contract: KeywordAnalysisWorkflowContract, contract: KeywordAnalysisWorkflowContract,
status: KeywordAnalysisWorkflowStatus, status: KeywordAnalysisWorkflowStatus,
@ -225,7 +282,7 @@ async function saveWorkflow(
} }
function mapWorkflowRow(row: KeywordAnalysisWorkflowRunRow | undefined) { function mapWorkflowRow(row: KeywordAnalysisWorkflowRunRow | undefined) {
return row?.output?.schemaVersion === "keyword-analysis-workflow.v1" ? row.output : null; return row?.output?.schemaVersion === "keyword-analysis-workflow.v1" ? normalizeWorkflowContract(row.output) : null;
} }
async function getWorkflowRun(runId: string) { async function getWorkflowRun(runId: string) {
@ -292,6 +349,219 @@ async function ensureProjectContextReady(projectId: string) {
return market; return market;
} }
async function ensureBusinessSynthesisReady(
contract: KeywordAnalysisWorkflowContract,
projectId: string,
semanticRunId: string,
demandCollectionProfile: "contextual" | "market_wide"
): Promise<{ contract: KeywordAnalysisWorkflowContract; ready: boolean }> {
if (demandCollectionProfile !== "market_wide") {
const completed = setStepStatus(contract, "business_synthesis", "completed", {
detail: "Контекстный охват не требует market-wide business synthesis перед Wordstat."
});
await saveWorkflow(completed, "running");
return { contract: completed, ready: true };
}
const currentBusinessSynthesis = await getLatestAlignedSeoBusinessSynthesis(projectId, semanticRunId);
if (currentBusinessSynthesis && currentBusinessSynthesis.provider.mode !== "deterministic_fallback") {
const ready = {
...setStepStatus(contract, "business_synthesis", "completed", {
detail:
`Product-frame готов: ${currentBusinessSynthesis.productFrame.productCategory} · ${currentBusinessSynthesis.productFrame.confidence}.`
}),
artifacts: {
...contract.artifacts,
businessSynthesisRunId: currentBusinessSynthesis.runId
}
};
await saveWorkflow(ready, "running");
return { contract: ready, ready: true };
}
if (contract.artifacts.businessSynthesisRunId) {
const providerStatus = await getSeoModelProviderStatusContract(projectId);
const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.businessSynthesisRunId);
if (!existingRun || isModelTaskRunning(existingRun.runStatus)) {
const waiting = setWorkflowStatus(
setStepStatus(contract, "business_synthesis", "waiting", {
reason: "AI Workspace собирает business synthesis перед рыночным frontier."
}),
"waiting",
{
activeStepId: "business_synthesis",
reason: "Ждём продуктовую рамку.",
summary: "Модель отделяет core offer, слои продукта, применения и guardrails."
}
);
waiting.nextActions = ["Дождаться seo.business_synthesis; затем workflow построит commercial demand."];
await saveWorkflow(waiting, "running");
return { contract: waiting, ready: false };
}
const failed = await failWorkflow(
contract,
"business_synthesis",
existingRun.errorMessage ?? "Business synthesis завершился без сохраненного product-frame evidence."
);
return { contract: failed ?? contract, ready: false };
}
const modelTaskRun = await runSeoModelTask(projectId, {
providerId: "codex_workspace",
taskType: "seo.business_synthesis"
});
const nextContract = {
...contract,
artifacts: {
...contract.artifacts,
businessSynthesisRunId: modelTaskRun.runId,
modelTaskRunId: modelTaskRun.runId
}
};
if (isModelTaskRunning(modelTaskRun.runStatus)) {
const waiting = setWorkflowStatus(
setStepStatus(nextContract, "business_synthesis", "waiting", {
reason: "AI Workspace принял business synthesis; ждём product-frame evidence."
}),
"waiting",
{
activeStepId: "business_synthesis",
reason: "Ждём продуктовую рамку.",
summary: "Модель отделяет core offer, слои продукта, применения и guardrails."
}
);
waiting.nextActions = ["Дождаться seo.business_synthesis; затем workflow построит commercial demand."];
await saveWorkflow(waiting, "running");
return { contract: waiting, ready: false };
}
if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") {
const failed = await failWorkflow(
nextContract,
"business_synthesis",
modelTaskRun.errorMessage ?? "Business synthesis не завершился успешно."
);
return { contract: failed ?? nextContract, ready: false };
}
return { contract: nextContract, ready: true };
}
async function ensureCommercialDemandReady(
contract: KeywordAnalysisWorkflowContract,
projectId: string,
semanticRunId: string,
demandCollectionProfile: "contextual" | "market_wide"
): Promise<{ contract: KeywordAnalysisWorkflowContract; ready: boolean }> {
if (demandCollectionProfile !== "market_wide") {
const completed = setStepStatus(contract, "commercial_demand", "completed", {
detail: "Контекстный охват не требует market-wide commercial demand перед Wordstat."
});
await saveWorkflow(completed, "running");
return { contract: completed, ready: true };
}
const currentCommercialDemand = await getLatestAlignedSeoCommercialDemand(projectId, semanticRunId);
if (currentCommercialDemand && currentCommercialDemand.provider.mode !== "deterministic_fallback") {
const ready = {
...setStepStatus(contract, "commercial_demand", "completed", {
detail:
`Buyer-intent frame готов: ${currentCommercialDemand.buyerIntentFamilies.length} families, ` +
`${currentCommercialDemand.applicationCommercialAngles.length} application angles.`
}),
artifacts: {
...contract.artifacts,
commercialDemandRunId: currentCommercialDemand.runId
}
};
await saveWorkflow(ready, "running");
return { contract: ready, ready: true };
}
if (contract.artifacts.commercialDemandRunId) {
const providerStatus = await getSeoModelProviderStatusContract(projectId);
const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.commercialDemandRunId);
if (!existingRun || isModelTaskRunning(existingRun.runStatus)) {
const waiting = setWorkflowStatus(
setStepStatus(contract, "commercial_demand", "waiting", {
reason: "AI Workspace строит buyer-intent frame перед Wordstat."
}),
"waiting",
{
activeStepId: "commercial_demand",
reason: "Ждём коммерческий спрос.",
summary: "Модель переводит продуктовую рамку в покупательские интенты и application demand."
}
);
waiting.nextActions = ["Дождаться seo.commercial_demand; затем workflow построит market frontier."];
await saveWorkflow(waiting, "running");
return { contract: waiting, ready: false };
}
const failed = await failWorkflow(
contract,
"commercial_demand",
existingRun.errorMessage ?? "Commercial demand завершился без сохраненного buyer-intent evidence."
);
return { contract: failed ?? contract, ready: false };
}
const modelTaskRun = await runSeoModelTask(projectId, {
providerId: "codex_workspace",
taskType: "seo.commercial_demand"
});
const nextContract = {
...contract,
artifacts: {
...contract.artifacts,
commercialDemandRunId: modelTaskRun.runId,
modelTaskRunId: modelTaskRun.runId
}
};
if (isModelTaskRunning(modelTaskRun.runStatus)) {
const waiting = setWorkflowStatus(
setStepStatus(nextContract, "commercial_demand", "waiting", {
reason: "AI Workspace принял commercial demand; ждём buyer-intent evidence."
}),
"waiting",
{
activeStepId: "commercial_demand",
reason: "Ждём коммерческий спрос.",
summary: "Модель переводит продуктовую рамку в покупательские интенты и application demand."
}
);
waiting.nextActions = ["Дождаться seo.commercial_demand; затем workflow построит market frontier."];
await saveWorkflow(waiting, "running");
return { contract: waiting, ready: false };
}
if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") {
const failed = await failWorkflow(
nextContract,
"commercial_demand",
modelTaskRun.errorMessage ?? "Commercial demand не завершился успешно."
);
return { contract: failed ?? nextContract, ready: false };
}
return { contract: nextContract, ready: true };
}
async function ensureMarketFrontierDiscoveryReady( async function ensureMarketFrontierDiscoveryReady(
contract: KeywordAnalysisWorkflowContract, contract: KeywordAnalysisWorkflowContract,
projectId: string, projectId: string,
@ -301,9 +571,9 @@ async function ensureMarketFrontierDiscoveryReady(
return { contract, ready: true }; return { contract, ready: true };
} }
if (contract.artifacts.modelTaskRunId) { if (contract.artifacts.marketFrontierRunId) {
const providerStatus = await getSeoModelProviderStatusContract(projectId); const providerStatus = await getSeoModelProviderStatusContract(projectId);
const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.modelTaskRunId); const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.marketFrontierRunId);
if (existingRun && isModelTaskRunning(existingRun.runStatus)) { if (existingRun && isModelTaskRunning(existingRun.runStatus)) {
const waiting = setWorkflowStatus( const waiting = setWorkflowStatus(
@ -358,6 +628,7 @@ async function ensureMarketFrontierDiscoveryReady(
...contract, ...contract,
artifacts: { artifacts: {
...contract.artifacts, ...contract.artifacts,
marketFrontierRunId: modelTaskRun.runId,
modelTaskRunId: modelTaskRun.runId modelTaskRunId: modelTaskRun.runId
} }
}; };
@ -473,6 +744,31 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string)
contract = setStepStatus(contract, "project_context", "completed", { contract = setStepStatus(contract, "project_context", "completed", {
detail: "Проектный контекст принят для анализа спроса." detail: "Проектный контекст принят для анализа спроса."
}); });
const businessSynthesis = await ensureBusinessSynthesisReady(
contract,
projectId,
anchorReview.semanticRunId,
anchorReview.demandCollectionProfile
);
contract = businessSynthesis.contract;
if (!businessSynthesis.ready) {
return;
}
const commercialDemand = await ensureCommercialDemandReady(
contract,
projectId,
anchorReview.semanticRunId,
anchorReview.demandCollectionProfile
);
contract = commercialDemand.contract;
if (!commercialDemand.ready) {
return;
}
contract = setWorkflowStatus(setStepStatus(contract, "demand_collection", "running"), "running", { contract = setWorkflowStatus(setStepStatus(contract, "demand_collection", "running"), "running", {
activeStepId: "demand_collection", activeStepId: "demand_collection",
summary: "Собираем рыночную базу Wordstat." summary: "Собираем рыночную базу Wordstat."
@ -553,6 +849,7 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string)
...contract, ...contract,
artifacts: { artifacts: {
...contract.artifacts, ...contract.artifacts,
keywordCleaningModelTaskRunId: modelTaskRun.runId,
modelTaskRunId: modelTaskRun.runId modelTaskRunId: modelTaskRun.runId
} }
}; };
@ -615,7 +912,7 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string)
async function finishKeywordCleaningWorkflow( async function finishKeywordCleaningWorkflow(
contract: KeywordAnalysisWorkflowContract, contract: KeywordAnalysisWorkflowContract,
projectId: string, projectId: string,
expectedModelTaskRunId: string | null = contract.artifacts.modelTaskRunId expectedModelTaskRunId: string | null = contract.artifacts.keywordCleaningModelTaskRunId ?? contract.artifacts.modelTaskRunId
) { ) {
const keywordCleaningMatch = expectedModelTaskRunId const keywordCleaningMatch = expectedModelTaskRunId
? await getKeywordCleaningByModelTaskRun(projectId, expectedModelTaskRunId) ? await getKeywordCleaningByModelTaskRun(projectId, expectedModelTaskRunId)
@ -666,18 +963,96 @@ async function finishKeywordCleaningWorkflow(
return completeWorkflow(nextContract); return completeWorkflow(nextContract);
} }
function getWaitingModelTaskRunId(contract: KeywordAnalysisWorkflowContract) {
if (contract.activeStepId === "business_synthesis") {
return contract.artifacts.businessSynthesisRunId ?? contract.artifacts.modelTaskRunId;
}
if (contract.activeStepId === "commercial_demand") {
return contract.artifacts.commercialDemandRunId ?? contract.artifacts.modelTaskRunId;
}
if (contract.activeStepId === "demand_collection") {
return contract.artifacts.marketFrontierRunId ?? contract.artifacts.modelTaskRunId;
}
if (contract.activeStepId === "keyword_cleaning") {
return contract.artifacts.keywordCleaningModelTaskRunId ?? contract.artifacts.modelTaskRunId;
}
return contract.artifacts.modelTaskRunId;
}
async function refreshWaitingWorkflow(contract: KeywordAnalysisWorkflowContract) { async function refreshWaitingWorkflow(contract: KeywordAnalysisWorkflowContract) {
if (contract.status !== "waiting" || !contract.artifacts.modelTaskRunId) { const waitingModelTaskRunId = getWaitingModelTaskRunId(contract);
if (contract.status !== "waiting" || !waitingModelTaskRunId) {
return contract; return contract;
} }
const providerStatus = await getSeoModelProviderStatusContract(contract.projectId); const providerStatus = await getSeoModelProviderStatusContract(contract.projectId);
const modelTaskRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.modelTaskRunId) ?? null; const modelTaskRun = providerStatus.latestRuns.find((run) => run.runId === waitingModelTaskRunId) ?? null;
if (!modelTaskRun || isModelTaskRunning(modelTaskRun.runStatus)) { if (!modelTaskRun || isModelTaskRunning(modelTaskRun.runStatus)) {
return contract; return contract;
} }
if (contract.activeStepId === "business_synthesis") {
if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") {
const failed = await failWorkflow(
contract,
"business_synthesis",
modelTaskRun.errorMessage ?? "Business synthesis не завершился успешно."
);
return failed ?? contract;
}
const resumed = setWorkflowStatus(
setStepStatus(contract, "business_synthesis", "running", {
detail: "Business synthesis готов; продолжаем commercial demand."
}),
"running",
{
activeStepId: "business_synthesis",
reason: null,
summary: "Продолжаем оркестрацию: строим buyer-intent слой."
}
);
resumed.nextActions = ["Workflow продолжит commercial demand; повторно нажимать кнопку не нужно."];
const saved = await saveWorkflow(resumed, "running");
void executeKeywordAnalysisWorkflow(contract.runId, contract.projectId);
return saved ?? resumed;
}
if (contract.activeStepId === "commercial_demand") {
if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") {
const failed = await failWorkflow(
contract,
"commercial_demand",
modelTaskRun.errorMessage ?? "Commercial demand не завершился успешно."
);
return failed ?? contract;
}
const resumed = setWorkflowStatus(
setStepStatus(contract, "commercial_demand", "running", {
detail: "Commercial demand готов; продолжаем market frontier discovery."
}),
"running",
{
activeStepId: "commercial_demand",
reason: null,
summary: "Продолжаем оркестрацию: строим market frontier перед Wordstat."
}
);
resumed.nextActions = ["Workflow продолжит market frontier и Wordstat; повторно нажимать кнопку не нужно."];
const saved = await saveWorkflow(resumed, "running");
void executeKeywordAnalysisWorkflow(contract.runId, contract.projectId);
return saved ?? resumed;
}
if (contract.activeStepId === "demand_collection") { if (contract.activeStepId === "demand_collection") {
const taskDidNotFinish = modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled"; const taskDidNotFinish = modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled";
const detail = taskDidNotFinish const detail = taskDidNotFinish

View File

@ -114,6 +114,19 @@ assert.ok(
keywordMapSource.includes("isEditorialOrLocalTailKeywordCandidate"), keywordMapSource.includes("isEditorialOrLocalTailKeywordCandidate"),
"Keyword cleaning/map must keep education-intent mismatch out of product lanes and preserve real editorial/local long-tail roles." "Keyword cleaning/map must keep education-intent mismatch out of product lanes and preserve real editorial/local long-tail roles."
); );
assert.ok(
keywordAnalysisWorkflowSource.includes("ensureBusinessSynthesisReady") &&
keywordAnalysisWorkflowSource.includes("ensureCommercialDemandReady") &&
keywordAnalysisWorkflowSource.indexOf("ensureBusinessSynthesisReady") <
keywordAnalysisWorkflowSource.indexOf("ensureCommercialDemandReady") &&
keywordAnalysisWorkflowSource.indexOf("ensureCommercialDemandReady") <
keywordAnalysisWorkflowSource.indexOf("ensureMarketFrontierDiscoveryReady") &&
keywordAnalysisWorkflowSource.includes('taskType: "seo.business_synthesis"') &&
keywordAnalysisWorkflowSource.includes('taskType: "seo.commercial_demand"') &&
appApiSource.includes('"business_synthesis"') &&
appApiSource.includes('"commercial_demand"'),
"Market-wide workflow must run business synthesis and commercial demand before market frontier discovery."
);
assert.ok( assert.ok(
keywordAnalysisWorkflowSource.includes("ensureMarketFrontierDiscoveryReady") && keywordAnalysisWorkflowSource.includes("ensureMarketFrontierDiscoveryReady") &&
keywordAnalysisWorkflowSource.includes('providerId: "codex_workspace"') && keywordAnalysisWorkflowSource.includes('providerId: "codex_workspace"') &&