@doxbrix/doxloop 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1153 -0
- package/README.md +422 -226
- package/assets/doxbrix-preview.css +77 -15
- package/contracts/agent-events-v1.schema.json +1 -0
- package/contracts/coverage-v1.schema.json +59 -0
- package/contracts/drift-v1.schema.json +1 -0
- package/contracts/evaluation-v1.schema.json +62 -0
- package/contracts/quality-codes-v1.json +1 -0
- package/contracts/quality-report-v1.schema.json +1 -0
- package/contracts/validation-v1.schema.json +1 -0
- package/dist/agent-failure.d.ts +36 -0
- package/dist/agent-failure.js +98 -0
- package/dist/agent-isolation.d.ts +17 -0
- package/dist/agent-isolation.js +94 -0
- package/dist/agent-log.d.ts +197 -0
- package/dist/agent-log.js +1094 -0
- package/dist/agent-process.d.ts +63 -0
- package/dist/agent-process.js +128 -0
- package/dist/agent-reply.d.ts +47 -0
- package/dist/agent-reply.js +315 -0
- package/dist/agents.d.ts +40 -4
- package/dist/agents.js +115 -11
- package/dist/api-coverage.d.ts +11 -0
- package/dist/api-coverage.js +72 -0
- package/dist/application-probe.d.ts +28 -0
- package/dist/application-probe.js +93 -0
- package/dist/application-url.d.ts +14 -0
- package/dist/application-url.js +26 -0
- package/dist/args.js +15 -0
- package/dist/artifact-deploy.d.ts +9 -0
- package/dist/artifact-deploy.js +28 -8
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +382 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +38 -9
- package/dist/author.d.ts +206 -4
- package/dist/author.js +1650 -52
- package/dist/authoring-batches.d.ts +190 -0
- package/dist/authoring-batches.js +489 -0
- package/dist/authoring-postpass.d.ts +79 -0
- package/dist/authoring-postpass.js +1289 -0
- package/dist/authoring-progress.d.ts +95 -0
- package/dist/authoring-progress.js +269 -0
- package/dist/autosync.d.ts +56 -0
- package/dist/autosync.js +442 -0
- package/dist/batch-artifacts.d.ts +81 -0
- package/dist/batch-artifacts.js +412 -0
- package/dist/batch-limits.d.ts +37 -0
- package/dist/batch-limits.js +80 -0
- package/dist/branding.d.ts +41 -0
- package/dist/branding.js +229 -0
- package/dist/bundle-upload.d.ts +26 -0
- package/dist/bundle-upload.js +99 -0
- package/dist/capture-auth.d.ts +95 -0
- package/dist/capture-auth.js +204 -0
- package/dist/capture.d.ts +48 -0
- package/dist/capture.js +20 -1
- package/dist/cli.js +839 -32
- package/dist/content-links.d.ts +10 -0
- package/dist/content-links.js +49 -0
- package/dist/contract-validation.d.ts +4 -0
- package/dist/contract-validation.js +25 -0
- package/dist/coverage-actions.d.ts +8 -0
- package/dist/coverage-actions.js +71 -0
- package/dist/coverage-resolutions.d.ts +16 -0
- package/dist/coverage-resolutions.js +58 -0
- package/dist/db.d.ts +37 -0
- package/dist/db.js +288 -0
- package/dist/demo.d.ts +12 -0
- package/dist/demo.js +122 -0
- package/dist/deploy-credentials.d.ts +7 -0
- package/dist/deploy-credentials.js +76 -0
- package/dist/deploy-targets/github-pages.d.ts +3 -0
- package/dist/deploy-targets/github-pages.js +100 -0
- package/dist/deploy-targets/index.d.ts +8 -0
- package/dist/deploy-targets/index.js +78 -0
- package/dist/deploy-targets/netlify.d.ts +3 -0
- package/dist/deploy-targets/netlify.js +49 -0
- package/dist/deploy-targets/types.d.ts +32 -0
- package/dist/deploy-targets/types.js +2 -0
- package/dist/deploy-targets/vercel.d.ts +3 -0
- package/dist/deploy-targets/vercel.js +95 -0
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +87 -13
- package/dist/deterministic-capture.d.ts +90 -0
- package/dist/deterministic-capture.js +435 -0
- package/dist/direct-edit.d.ts +40 -0
- package/dist/direct-edit.js +159 -0
- package/dist/docs-crawl.d.ts +86 -0
- package/dist/docs-crawl.js +536 -0
- package/dist/docs-site.d.ts +49 -0
- package/dist/docs-site.js +233 -0
- package/dist/doctor.js +8 -0
- package/dist/documentation-collections.d.ts +23 -0
- package/dist/documentation-collections.js +191 -0
- package/dist/documentation-plan.d.ts +253 -0
- package/dist/documentation-plan.js +2563 -0
- package/dist/doxbrix-build.d.ts +19 -0
- package/dist/doxbrix-build.js +167 -0
- package/dist/doxbrix-markdown.d.ts +9 -0
- package/dist/doxbrix-markdown.js +115 -14
- package/dist/drift.d.ts +10 -0
- package/dist/drift.js +164 -0
- package/dist/evaluation.d.ts +46 -0
- package/dist/evaluation.js +113 -0
- package/dist/evidence-pack.d.ts +47 -0
- package/dist/evidence-pack.js +358 -0
- package/dist/evidence.d.ts +25 -0
- package/dist/evidence.js +175 -0
- package/dist/fs.d.ts +8 -2
- package/dist/fs.js +41 -11
- package/dist/generator-api.d.ts +89 -0
- package/dist/generator-preflight.d.ts +27 -0
- package/dist/generator-preflight.js +105 -0
- package/dist/generator-runtime.d.ts +7 -0
- package/dist/generator-runtime.js +17 -1
- package/dist/generators.d.ts +24 -2
- package/dist/generators.js +54 -1
- package/dist/git-delivery.d.ts +17 -0
- package/dist/git-delivery.js +123 -0
- package/dist/globs.d.ts +16 -0
- package/dist/globs.js +65 -0
- package/dist/glossary.d.ts +26 -0
- package/dist/glossary.js +179 -0
- package/dist/history.d.ts +106 -0
- package/dist/history.js +600 -0
- package/dist/html-markdown.d.ts +46 -0
- package/dist/html-markdown.js +423 -0
- package/dist/interactive.js +16 -15
- package/dist/job-events.d.ts +74 -0
- package/dist/job-events.js +377 -0
- package/dist/keep-awake.d.ts +50 -0
- package/dist/keep-awake.js +123 -0
- package/dist/local-source-snapshot.d.ts +20 -0
- package/dist/local-source-snapshot.js +61 -0
- package/dist/mintlify-detect.d.ts +3 -0
- package/dist/mintlify-detect.js +18 -0
- package/dist/mintlify-import.d.ts +75 -0
- package/dist/mintlify-import.js +190 -0
- package/dist/navigation.d.ts +98 -0
- package/dist/navigation.js +310 -0
- package/dist/openapi.d.ts +60 -0
- package/dist/openapi.js +439 -0
- package/dist/page-editor-bridge.d.ts +3 -0
- package/dist/page-editor-bridge.js +109 -0
- package/dist/page-editor-preview.d.ts +10 -0
- package/dist/page-editor-preview.js +55 -0
- package/dist/page-extension.d.ts +9 -0
- package/dist/page-extension.js +15 -0
- package/dist/page-metadata.d.ts +28 -0
- package/dist/page-metadata.js +166 -0
- package/dist/page-operations.d.ts +34 -0
- package/dist/page-operations.js +215 -0
- package/dist/page-routes.d.ts +4 -0
- package/dist/page-routes.js +61 -0
- package/dist/pages.d.ts +20 -0
- package/dist/pages.js +184 -0
- package/dist/plan-generator.d.ts +3 -0
- package/dist/plan-generator.js +21 -0
- package/dist/plan-navigation.d.ts +11 -0
- package/dist/plan-navigation.js +30 -0
- package/dist/planning-captures.d.ts +20 -0
- package/dist/planning-captures.js +143 -0
- package/dist/planning-research.d.ts +135 -0
- package/dist/planning-research.js +472 -0
- package/dist/planning-triage.d.ts +23 -0
- package/dist/planning-triage.js +131 -0
- package/dist/preview.d.ts +24 -0
- package/dist/preview.js +280 -29
- package/dist/project-detect.d.ts +36 -0
- package/dist/project-detect.js +251 -0
- package/dist/project-import.d.ts +54 -0
- package/dist/project-import.js +157 -0
- package/dist/project-lock.d.ts +6 -0
- package/dist/project-lock.js +96 -0
- package/dist/project-registry.d.ts +25 -0
- package/dist/project-registry.js +79 -0
- package/dist/project.d.ts +35 -3
- package/dist/project.js +388 -38
- package/dist/prompts.d.ts +9 -0
- package/dist/prompts.js +33 -4
- package/dist/proposal-replay.d.ts +32 -0
- package/dist/proposal-replay.js +99 -0
- package/dist/quality-claims.d.ts +8 -0
- package/dist/quality-claims.js +168 -0
- package/dist/quality-config.d.ts +5 -0
- package/dist/quality-config.js +84 -0
- package/dist/quality-contract.d.ts +37 -0
- package/dist/quality-contract.js +46 -0
- package/dist/quality-examples.d.ts +4 -0
- package/dist/quality-examples.js +233 -0
- package/dist/quality-gates.d.ts +16 -0
- package/dist/quality-gates.js +192 -0
- package/dist/quality-links.d.ts +7 -0
- package/dist/quality-links.js +149 -0
- package/dist/quality-lint.d.ts +6 -0
- package/dist/quality-lint.js +124 -0
- package/dist/quality-rendered.d.ts +11 -0
- package/dist/quality-rendered.js +222 -0
- package/dist/quality-schema.d.ts +3 -0
- package/dist/quality-schema.js +71 -0
- package/dist/release-notes.d.ts +44 -0
- package/dist/release-notes.js +183 -0
- package/dist/remote-monitor.d.ts +16 -0
- package/dist/remote-monitor.js +74 -0
- package/dist/remote-source.d.ts +34 -0
- package/dist/remote-source.js +426 -0
- package/dist/review-diff.d.ts +82 -0
- package/dist/review-diff.js +400 -0
- package/dist/review-learning.d.ts +11 -0
- package/dist/review-learning.js +60 -0
- package/dist/review-render.d.ts +38 -0
- package/dist/review-render.js +224 -0
- package/dist/review-report.d.ts +9 -0
- package/dist/review-report.js +89 -0
- package/dist/review-ui.d.ts +14 -0
- package/dist/review-ui.js +1248 -0
- package/dist/schedule.d.ts +78 -0
- package/dist/schedule.js +480 -0
- package/dist/screen-capture-provider.d.ts +52 -0
- package/dist/screen-capture-provider.js +218 -0
- package/dist/screenshot-workflow.d.ts +167 -0
- package/dist/screenshot-workflow.js +1237 -0
- package/dist/settings.d.ts +1 -1
- package/dist/settings.js +95 -7
- package/dist/site-export.d.ts +18 -0
- package/dist/site-export.js +87 -0
- package/dist/source-connectors.d.ts +33 -0
- package/dist/source-connectors.js +268 -0
- package/dist/source-discovery.d.ts +132 -0
- package/dist/source-discovery.js +823 -0
- package/dist/source-intelligence.d.ts +9 -0
- package/dist/source-intelligence.js +306 -0
- package/dist/sync-review.d.ts +28 -0
- package/dist/sync-review.js +264 -0
- package/dist/sync-runs.d.ts +192 -0
- package/dist/sync-runs.js +2244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +298 -32
- package/dist/text-diff.d.ts +9 -0
- package/dist/text-diff.js +59 -0
- package/dist/types.d.ts +946 -1
- package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
- package/dist/ui/assets/index-BHBYU2aG.css +1 -0
- package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
- package/dist/ui/index.html +18 -0
- package/dist/ui-server.d.ts +83 -0
- package/dist/ui-server.js +3532 -0
- package/dist/usage-budget.d.ts +28 -0
- package/dist/usage-budget.js +90 -0
- package/dist/validation.d.ts +25 -1
- package/dist/validation.js +312 -26
- package/dist/workspace-tools.d.ts +54 -0
- package/dist/workspace-tools.js +123 -0
- package/docs/agent-compatibility.md +50 -28
- package/docs/ci-and-automation.md +105 -66
- package/docs/doxbrix-http-api.md +8 -1
- package/docs/existing-documentation.md +80 -0
- package/docs/generation-performance.md +108 -0
- package/docs/generator-authoring.md +68 -5
- package/docs/generator-selection.md +50 -13
- package/docs/mintlify-import.md +71 -0
- package/docs/openapi-security.md +25 -0
- package/docs/project-format.md +331 -36
- package/docs/release-quality.md +158 -0
- package/docs/releasing.md +72 -0
- package/docs/review-workflows.md +51 -0
- package/docs/security-model.md +102 -39
- package/docs/troubleshooting.md +226 -93
- package/package.json +46 -15
- package/scripts/test-auto-screenshot.mjs +172 -0
- package/skills/doxloop-authoring/SKILL.md +242 -361
- package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
- package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
- package/skills/doxloop-authoring/references/page-depth.md +169 -0
- package/skills/doxloop-authoring/references/project-format.md +106 -7
- package/skills/doxloop-authoring/references/quality.md +10 -0
- package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
- package/skills/doxloop-authoring/references/screenshots.md +149 -235
- package/skills/doxloop-authoring/references/workflows.md +76 -0
- package/skills/doxloop-doxbrix/SKILL.md +38 -22
- package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
- package/skills/doxloop-doxbrix/references/components.md +30 -3
- package/skills/doxloop-doxbrix/references/manifest.md +3 -2
- package/vendor/doxbrix-import/LICENSE +202 -0
- package/vendor/doxbrix-import/README.md +17 -0
- package/vendor/doxbrix-import/UPSTREAM.json +47 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
- package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
- package/vendor/doxbrix-import/dist/docs/import.js +147 -0
- package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
- package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
- package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
- package/vendor/doxbrix-import/dist/docs/project.js +77 -0
- package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
- package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
- package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
- package/vendor/doxbrix-import/dist/importer.js +1567 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
- package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
- package/vendor/doxbrix-import/dist/safe-path.js +47 -0
- package/dist/agents.d.ts.map +0 -1
- package/dist/agents.js.map +0 -1
- package/dist/args.d.ts.map +0 -1
- package/dist/args.js.map +0 -1
- package/dist/artifact-deploy.d.ts.map +0 -1
- package/dist/artifact-deploy.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/author.d.ts.map +0 -1
- package/dist/author.js.map +0 -1
- package/dist/capture.d.ts.map +0 -1
- package/dist/capture.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/deploy.d.ts.map +0 -1
- package/dist/deploy.js.map +0 -1
- package/dist/deployment-visibility.d.ts.map +0 -1
- package/dist/deployment-visibility.js.map +0 -1
- package/dist/doctor.d.ts.map +0 -1
- package/dist/doctor.js.map +0 -1
- package/dist/doxbrix-markdown.d.ts.map +0 -1
- package/dist/doxbrix-markdown.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/fs.d.ts.map +0 -1
- package/dist/fs.js.map +0 -1
- package/dist/generator-api.d.ts.map +0 -1
- package/dist/generator-api.js.map +0 -1
- package/dist/generator-manager.d.ts.map +0 -1
- package/dist/generator-manager.js.map +0 -1
- package/dist/generator-runtime.d.ts.map +0 -1
- package/dist/generator-runtime.js.map +0 -1
- package/dist/generators.d.ts.map +0 -1
- package/dist/generators.js.map +0 -1
- package/dist/interactive.d.ts.map +0 -1
- package/dist/interactive.js.map +0 -1
- package/dist/preview.d.ts.map +0 -1
- package/dist/preview.js.map +0 -1
- package/dist/progress.d.ts.map +0 -1
- package/dist/progress.js.map +0 -1
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/prompts.d.ts.map +0 -1
- package/dist/prompts.js.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/settings.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,3532 @@
|
|
|
1
|
+
import { pageEditorPreview } from './page-editor-preview.js';
|
|
2
|
+
import { documentationCollections, createDocumentationCollection } from './documentation-collections.js';
|
|
3
|
+
import { pageComments, savePageComment, searchPageText, auditDocumentation, backfillEvidence, readerVerification, authoringEstimate } from './workspace-tools.js';
|
|
4
|
+
import { updateBulkPageMetadata } from './page-metadata.js';
|
|
5
|
+
import { batchLimits, defaultBatchLimits } from './batch-limits.js';
|
|
6
|
+
import { readPageContent, savePageContent, previewPageContent, changePageLifecycle } from './page-operations.js';
|
|
7
|
+
import { listDirectEdits, undoDirectEdit } from './direct-edit.js';
|
|
8
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { AGENT_STOP_GRACE_MS, signalTree } from './agent-process.js';
|
|
11
|
+
import { access, appendFile, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { createServer as createNetServer } from 'node:net';
|
|
14
|
+
import { extname, join, relative, resolve, sep } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { hashRouted } from './application-url.js';
|
|
17
|
+
import { AGENT_CATALOG, installAgent, installSkill, parseAgent, skillStatus, detectAgents, agentAuthenticationStatus, chooseAgent } from './agents.js';
|
|
18
|
+
import { agentDisplayName, agentSignedOutMessage } from './agent-failure.js';
|
|
19
|
+
import { applySyncConfig, computeConfiguredDrift, disableSync, formatSyncStatus, parseSyncMode, parseTriggerList } from './autosync.js';
|
|
20
|
+
import { authenticatedRequest, authenticatedRequestOptional, loadUserConfig, logout, signedInServers } from './auth.js';
|
|
21
|
+
import { parseClaudeEffort, parseReasoning } from './author.js';
|
|
22
|
+
import { historyAvailable } from './db.js';
|
|
23
|
+
import { approveDocumentationPlan, beginDocumentationPlanRevision, cancelDocumentationPlan, createDocumentationPlan, documentationPlanClarificationFeedback, editDocumentationPlan, ignoreDocumentationPlanError, latestDocumentationPlan, listDocumentationPlanVersions, readDocumentationPlan, resumeDocumentationPlan, retryDocumentationPlan, markDocumentationPlanInterrupted, updateDocumentationPlanExecution, } from './documentation-plan.js';
|
|
24
|
+
import { DoxloopError } from './errors.js';
|
|
25
|
+
import { deployCredentialState, saveDeployCredential } from './deploy-credentials.js';
|
|
26
|
+
import { applyWorkflowStageLine, cleanJobOutputLines, finishWorkflowStages, JobLogDisplayFilter, parseJobOutcome, parseJobOutcomeLine, parseWorkflowStage, stampJobLine, stripJobLineStamp } from './job-events.js';
|
|
27
|
+
import { keepAwake } from './keep-awake.js';
|
|
28
|
+
import { createProposalBranch, publishProposalBranch } from './git-delivery.js';
|
|
29
|
+
import { addGenerator } from './generator-manager.js';
|
|
30
|
+
import { backfillHistory, listDeployments, listRequests, pageHistory, requestPages, } from './history.js';
|
|
31
|
+
import { generatorPreflight } from './generator-preflight.js';
|
|
32
|
+
import { installedGeneratorEntries, parseGenerator } from './generators.js';
|
|
33
|
+
import { runDoctor } from './doctor.js';
|
|
34
|
+
import { assertNewProjectDirectory, completeDocumentationBriefForCreate, defaultDocumentationBrief, findProjectRoot, isSpecUrl, loadProject, parseDesignReference, parseSpec, projectDefaultModel, saveProjectSettings, scaffoldProject, validateProjectSourceBoundaries, } from './project.js';
|
|
35
|
+
import { importExistingDocumentation, inspectExistingDocumentation } from './project-import.js';
|
|
36
|
+
import { MintlifyImports } from './mintlify-import.js';
|
|
37
|
+
import { forgetProject, listRecentProjects, rememberProject } from './project-registry.js';
|
|
38
|
+
import { effectiveDeployment } from './settings.js';
|
|
39
|
+
import { assertScreenshotPlanningReadiness, checkApplicationReadiness, normalizeScreenshotIntent } from './screenshot-workflow.js';
|
|
40
|
+
import { checkScreenCaptureBrowser, startCaptureSignIn } from './screen-capture-provider.js';
|
|
41
|
+
import { captureAuthContext, captureAuthStatus, loadCaptureCredentials, loadCaptureSession, removeCaptureCredentials, removeCaptureSession, saveCaptureCredentials, saveCaptureSession, sessionCookieHeader, } from './capture-auth.js';
|
|
42
|
+
import { DEFAULT_DOCS_CRAWL_PAGE_LIMIT, MAX_DOCS_CRAWL_PAGE_LIMIT, crawlDocumentationSite, docsSiteScope } from './docs-crawl.js';
|
|
43
|
+
import { docsSiteBinding, materializeDocsSiteSnapshot } from './docs-site.js';
|
|
44
|
+
import { discoverDocumentationSources } from './source-discovery.js';
|
|
45
|
+
import { buildSourceIntelligence } from './source-intelligence.js';
|
|
46
|
+
import { resolveCoverageItem } from './coverage-actions.js';
|
|
47
|
+
import { connectorForSource } from './source-connectors.js';
|
|
48
|
+
import { assertInside, ensureGitignoreEntries, pathExists } from './fs.js';
|
|
49
|
+
import { loadOpenApiSource, parseOpenApi } from './openapi.js';
|
|
50
|
+
import { listPages as listDocumentationPages, resolveEditScope } from './pages.js';
|
|
51
|
+
import { readNavigation, writeNavigation } from './navigation.js';
|
|
52
|
+
import { readBranding, writeBranding } from './branding.js';
|
|
53
|
+
import { ASSET_FILE_EXTENSIONS, ASSET_IMAGE_EXTENSIONS, deleteAsset, readAssetLibrary, replaceRunCapture, updateAssetAlt, uploadAsset } from './assets.js';
|
|
54
|
+
import { readPageMetadata, updatePageMetadata } from './page-metadata.js';
|
|
55
|
+
import { generateGlossaryPage, readGlossary } from './glossary.js';
|
|
56
|
+
import { gitBackedSources, listSourceRefs } from './release-notes.js';
|
|
57
|
+
import { testRemoteSource } from './remote-monitor.js';
|
|
58
|
+
import { listRemoteBranches, listRemoteDirectories, materializeRemoteSource, parseGitRepository, parseGitHubRepository, portableSourcePath, rememberRemoteCredential, remoteCredentialEnvironment, remoteHead, } from './remote-source.js';
|
|
59
|
+
import { acceptSyncChanges, archiveSyncRun, createRunId, editSyncRunChange, listSyncRuns, markInterruptedSyncRuns, pruneSyncRuns, readSyncRun, recoverSyncRun, runWorkspace, readSyncRunChangeContent, rejectSyncRun, rejectSyncChanges, undoSyncRun, } from './sync-runs.js';
|
|
60
|
+
import { requireSyncReviewChange, syncReviewComparisonDocument, syncReviewAcceptOptions, syncReviewSelectionsFromBody, syncReviewSourceDiff, } from './sync-review.js';
|
|
61
|
+
import { validateProject } from './validation.js';
|
|
62
|
+
/** Inspections are discarded after this long so abandoned crawls do not hold page bodies forever. */
|
|
63
|
+
const DOCS_SITE_INSPECTION_TTL_MS = 30 * 60 * 1000;
|
|
64
|
+
const UI_ROOT = resolve(fileURLToPath(new URL('./ui', import.meta.url)));
|
|
65
|
+
const PACKAGE_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
66
|
+
const CLI_PATH = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
67
|
+
const DOXBRIX_CSS = resolve(PACKAGE_ROOT, 'assets', 'doxbrix-preview.css');
|
|
68
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
69
|
+
/** Base64 image uploads: a 10 MB asset plus JSON framing. */
|
|
70
|
+
const MAX_ASSET_BODY_BYTES = 15_000_000;
|
|
71
|
+
const MAX_PERSISTED_JOBS = 50;
|
|
72
|
+
// Agents stream one JSON line per token delta, so a single planning run leaves
|
|
73
|
+
// megabytes of output behind. The full log stays on disk under
|
|
74
|
+
// UI_JOB_LOG_DIRECTORY and is served by /api/jobs/:id/log; the in-memory copy
|
|
75
|
+
// that every state snapshot, job-stream event, and ui-jobs.json write carries
|
|
76
|
+
// keeps only a recent tail. Without this cap a finished 2 MB job was
|
|
77
|
+
// re-serialized on every output chunk of the next run, which pinned the server
|
|
78
|
+
// and left the control center unresponsive.
|
|
79
|
+
const MAX_JOB_LINES = 400;
|
|
80
|
+
const MAX_JOB_LINE_LENGTH = 4_000;
|
|
81
|
+
const JOB_BROADCAST_INTERVAL_MS = 150;
|
|
82
|
+
const UI_JOBS_FILE = join('.doxloop', 'ui-jobs.json');
|
|
83
|
+
const UI_JOB_LOG_DIRECTORY = join('.doxloop', 'ui-job-logs');
|
|
84
|
+
// The CLI gets the agent's own grace period plus a margin to write its exit.
|
|
85
|
+
const JOB_STOP_GRACE_MS = AGENT_STOP_GRACE_MS + 5_000;
|
|
86
|
+
/**
|
|
87
|
+
* The setup wizard creates a project in a subfolder of the directory the
|
|
88
|
+
* control center was started from. Started there again, the control center
|
|
89
|
+
* showed an empty wizard as if the project were gone; it now reopens the
|
|
90
|
+
* most recently used project inside that directory.
|
|
91
|
+
*/
|
|
92
|
+
async function recentProjectInside(cwd) {
|
|
93
|
+
const parent = resolve(cwd);
|
|
94
|
+
for (const project of await listRecentProjects()) {
|
|
95
|
+
if (project.missing || !project.path.startsWith(`${parent}${sep}`))
|
|
96
|
+
continue;
|
|
97
|
+
const root = await optionalProjectRoot(project.path);
|
|
98
|
+
if (root)
|
|
99
|
+
return root;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
export async function startUiServer(options) {
|
|
104
|
+
const host = options.host ?? '127.0.0.1';
|
|
105
|
+
if (host !== '127.0.0.1' && host !== 'localhost') {
|
|
106
|
+
throw new DoxloopError('The Doxloop UI is local-only and must bind to 127.0.0.1.');
|
|
107
|
+
}
|
|
108
|
+
const port = options.port ?? 4317;
|
|
109
|
+
const page = normalizeInitialPage(options.page);
|
|
110
|
+
const root = options.project
|
|
111
|
+
? await findProjectRoot(resolve(options.cwd, options.project))
|
|
112
|
+
: await optionalProjectRoot(options.cwd) ?? await recentProjectInside(options.cwd);
|
|
113
|
+
const { jobs, recovered: recoveredJobs } = root ? await loadRuntimeJobs(root) : { jobs: new Map(), recovered: false };
|
|
114
|
+
const runtime = {
|
|
115
|
+
mintlifyImports: new MintlifyImports(),
|
|
116
|
+
controlCenterUrl: `http://127.0.0.1:${port}`,
|
|
117
|
+
cwd: resolve(options.cwd),
|
|
118
|
+
root,
|
|
119
|
+
jobs,
|
|
120
|
+
jobSubscribers: new Set(),
|
|
121
|
+
jobPersistQueue: Promise.resolve(),
|
|
122
|
+
jobLogQueues: new Map(),
|
|
123
|
+
docsSiteInspections: new Map(),
|
|
124
|
+
};
|
|
125
|
+
if (recoveredJobs)
|
|
126
|
+
await persistUiJobs(runtime);
|
|
127
|
+
if (root)
|
|
128
|
+
await rememberOpenProject(root);
|
|
129
|
+
const session = randomBytes(32).toString('hex');
|
|
130
|
+
const server = createServer((request, response) => {
|
|
131
|
+
void handleUiRequest(request, response, runtime, session, port);
|
|
132
|
+
});
|
|
133
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
134
|
+
server.once('error', rejectListen);
|
|
135
|
+
server.listen(port, host, resolveListen);
|
|
136
|
+
});
|
|
137
|
+
const url = `http://${host}:${port}/${page}`;
|
|
138
|
+
process.stdout.write(`Doxloop UI: ${url}\nLocal project data stays on this computer.\nPress Ctrl+C to stop.\n`);
|
|
139
|
+
if (options.open !== false)
|
|
140
|
+
openBrowser(url);
|
|
141
|
+
let stopping = false;
|
|
142
|
+
const stop = async () => {
|
|
143
|
+
if (stopping)
|
|
144
|
+
return;
|
|
145
|
+
stopping = true;
|
|
146
|
+
for (const job of runtime.jobs.values()) {
|
|
147
|
+
if (job.status !== 'running')
|
|
148
|
+
continue;
|
|
149
|
+
job.status = 'cancelled';
|
|
150
|
+
job.finishedAt = new Date().toISOString();
|
|
151
|
+
noteJob(runtime, job, 'The Doxloop UI server stopped and cancelled this run.');
|
|
152
|
+
stopJobChild(job.child);
|
|
153
|
+
if (runtime.root && job.planId)
|
|
154
|
+
await cancelDocumentationPlan(runtime.root, job.planId).catch(() => undefined);
|
|
155
|
+
}
|
|
156
|
+
broadcastJobs(runtime);
|
|
157
|
+
await flushUiJobLogs(runtime);
|
|
158
|
+
await flushUiJobs(runtime);
|
|
159
|
+
await runtime.mintlifyImports.close();
|
|
160
|
+
for (const response of runtime.jobSubscribers)
|
|
161
|
+
response.end();
|
|
162
|
+
runtime.jobSubscribers.clear();
|
|
163
|
+
await new Promise((resolveClose) => server.close(() => resolveClose()));
|
|
164
|
+
};
|
|
165
|
+
process.once('SIGINT', () => void stop());
|
|
166
|
+
process.once('SIGTERM', () => void stop());
|
|
167
|
+
}
|
|
168
|
+
async function handleUiRequest(request, response, runtime, session, port) {
|
|
169
|
+
try {
|
|
170
|
+
requireLocalHost(request, port);
|
|
171
|
+
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
|
|
172
|
+
if (url.pathname.startsWith('/api/')) {
|
|
173
|
+
requireSession(request, session, port);
|
|
174
|
+
if (request.method !== 'GET')
|
|
175
|
+
requireSameOrigin(request, port);
|
|
176
|
+
await handleApi(request, response, url, runtime);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (url.pathname.startsWith('/review-preview/')) {
|
|
180
|
+
requireSession(request, session, port);
|
|
181
|
+
await handleProposalPreview(response, url, runtime);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (url.pathname.startsWith('/assets/')) {
|
|
185
|
+
await serveStatic(response, UI_ROOT, url.pathname.slice(1), false);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (url.pathname === '/reader.css') {
|
|
189
|
+
await serveFile(response, DOXBRIX_CSS, false);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (url.pathname === '/brand/favicon.png') {
|
|
193
|
+
await serveFile(response, join(PACKAGE_ROOT, 'assets', 'brand', 'doxloop-favicon.png'), false);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
response.setHeader('Set-Cookie', `${uiSessionCookieName(port)}=${session}; HttpOnly; SameSite=Strict; Path=/`);
|
|
197
|
+
await serveFile(response, join(UI_ROOT, 'index.html'), true);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const status = uiErrorStatus(error);
|
|
201
|
+
sendJson(response, status, { error: error instanceof Error ? error.message : String(error) });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function handleApi(request, response, url, runtime) {
|
|
205
|
+
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
206
|
+
sendJson(response, 200, await buildUiState(runtime));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/browse-directory') {
|
|
210
|
+
sendJson(response, 200, { path: await chooseLocalDirectory('Choose product source directory') ?? null });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (request.method === 'GET' && url.pathname === '/api/projects') {
|
|
214
|
+
sendJson(response, 200, {
|
|
215
|
+
current: runtime.root ?? null,
|
|
216
|
+
recent: await listRecentProjects(),
|
|
217
|
+
// The full catalog, not the open project's generator, so the import dialog can correct a wrong guess.
|
|
218
|
+
generators: await installedGeneratorEntries(runtime.cwd),
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (request.method === 'POST' && url.pathname === '/api/projects/browse') {
|
|
223
|
+
sendJson(response, 200, { path: await chooseLocalDirectory('Choose a documentation folder') ?? null });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (request.method === 'POST' && url.pathname === '/api/projects/open') {
|
|
227
|
+
const body = recordBody(await readJsonBody(request));
|
|
228
|
+
await switchProject(runtime, await resolveProjectToOpen(runtime, stringValue(body.path)));
|
|
229
|
+
sendJson(response, 200, await buildUiState(runtime));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (request.method === 'POST' && url.pathname === '/api/projects/forget') {
|
|
233
|
+
const body = recordBody(await readJsonBody(request));
|
|
234
|
+
sendJson(response, 200, { recent: await forgetProject(resolve(runtime.cwd, stringValue(body.path))) });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (request.method === 'POST' && url.pathname === '/api/projects/inspect') {
|
|
238
|
+
const body = recordBody(await readJsonBody(request));
|
|
239
|
+
sendJson(response, 200, await inspectExistingDocumentation(resolve(runtime.cwd, stringValue(body.path)), importOverrides(body)));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (request.method === 'POST' && url.pathname === '/api/projects/mintlify/inspect') {
|
|
243
|
+
const body = recordBody(await readJsonBody(request));
|
|
244
|
+
const repository = optionalString(body.repository);
|
|
245
|
+
if (repository)
|
|
246
|
+
configureGitAccess(body, `https://github.com/${parseGitHubRepository(repository)}.git`);
|
|
247
|
+
sendJson(response, 200, await runtime.mintlifyImports.inspect({
|
|
248
|
+
...(repository ? { repository } : { path: stringValue(body.path) }),
|
|
249
|
+
...(optionalString(body.branch) ? { branch: optionalString(body.branch) } : {}),
|
|
250
|
+
...(optionalString(body.subdirectory) ? { subdirectory: optionalString(body.subdirectory) } : {}),
|
|
251
|
+
}, runtime.cwd));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (request.method === 'POST' && url.pathname === '/api/projects/mintlify/discard') {
|
|
255
|
+
const body = recordBody(await readJsonBody(request));
|
|
256
|
+
await runtime.mintlifyImports.discard(stringValue(body.id));
|
|
257
|
+
sendJson(response, 200, { ok: true });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (request.method === 'POST' && url.pathname === '/api/projects/mintlify/convert') {
|
|
261
|
+
const body = recordBody(await readJsonBody(request));
|
|
262
|
+
assertProjectSwitchAllowed(runtime.jobs.values());
|
|
263
|
+
const imported = await runtime.mintlifyImports.convert(stringValue(body.id), resolve(runtime.cwd, stringValue(body.destination)), body.allowWarnings === true);
|
|
264
|
+
await switchProject(runtime, imported.root);
|
|
265
|
+
sendJson(response, 201, { imported });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (request.method === 'POST' && url.pathname === '/api/docs-site/inspect') {
|
|
269
|
+
const body = recordBody(await readJsonBody(request));
|
|
270
|
+
sendJson(response, 202, docsSiteInspectionState(await startDocsSiteInspection(runtime, body)));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const docsSiteInspection = /^\/api\/docs-site\/inspect\/([^/]+)$/.exec(url.pathname);
|
|
274
|
+
if (docsSiteInspection && request.method === 'GET') {
|
|
275
|
+
const inspection = runtime.docsSiteInspections.get(decodeURIComponent(docsSiteInspection[1]));
|
|
276
|
+
if (!inspection)
|
|
277
|
+
throw new DoxloopError('The documentation site crawl has expired. Crawl the site again.');
|
|
278
|
+
sendJson(response, 200, docsSiteInspectionState(inspection));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (docsSiteInspection && request.method === 'DELETE') {
|
|
282
|
+
const inspection = runtime.docsSiteInspections.get(decodeURIComponent(docsSiteInspection[1]));
|
|
283
|
+
if (inspection) {
|
|
284
|
+
inspection.controller.abort();
|
|
285
|
+
runtime.docsSiteInspections.delete(inspection.id);
|
|
286
|
+
}
|
|
287
|
+
sendJson(response, 200, { ok: true });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (request.method === 'POST' && url.pathname === '/api/projects/import') {
|
|
291
|
+
const body = recordBody(await readJsonBody(request));
|
|
292
|
+
assertProjectSwitchAllowed(runtime.jobs.values());
|
|
293
|
+
const directory = resolve(runtime.cwd, stringValue(body.path));
|
|
294
|
+
const overrides = importOverrides(body);
|
|
295
|
+
if (body.installGenerator === true) {
|
|
296
|
+
const inspection = await inspectExistingDocumentation(directory, overrides);
|
|
297
|
+
if (inspection.generator && !inspection.generatorInstalled)
|
|
298
|
+
await addGenerator(directory, inspection.generator);
|
|
299
|
+
}
|
|
300
|
+
const agent = parseAgent(optionalString(body.agent));
|
|
301
|
+
const imported = await importExistingDocumentation({
|
|
302
|
+
directory,
|
|
303
|
+
...overrides,
|
|
304
|
+
...(optionalString(body.title) ? { title: optionalString(body.title) } : {}),
|
|
305
|
+
...(agent ? { agent } : {}),
|
|
306
|
+
});
|
|
307
|
+
if (agent)
|
|
308
|
+
await saveProjectSettings(imported.root, { defaultAgent: agent });
|
|
309
|
+
await switchProject(runtime, imported.root);
|
|
310
|
+
sendJson(response, 201, { ...(await buildUiState(runtime)), imported });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/validate') {
|
|
314
|
+
sendJson(response, 200, await validateSetupPaths(runtime, await readJsonBody(request)));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/git/test') {
|
|
318
|
+
const body = recordBody(await readJsonBody(request));
|
|
319
|
+
const repository = parseGitRepository(stringValue(body.repository));
|
|
320
|
+
configureGitAccess(body, repository);
|
|
321
|
+
const seed = {
|
|
322
|
+
provider: 'git',
|
|
323
|
+
repository,
|
|
324
|
+
branch: optionalString(body.branch) ?? 'main',
|
|
325
|
+
};
|
|
326
|
+
const branches = await listRemoteBranches(seed);
|
|
327
|
+
if (branches.length === 0)
|
|
328
|
+
throw new DoxloopError('The repository has no readable branches.');
|
|
329
|
+
const requested = optionalString(body.branch);
|
|
330
|
+
const selected = branches.find((branch) => branch.name === requested)
|
|
331
|
+
?? branches[0];
|
|
332
|
+
sendJson(response, 200, { repository, branch: selected.name, head: selected.head, branches: branches.map((branch) => branch.name) });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/git/folders') {
|
|
336
|
+
const body = recordBody(await readJsonBody(request));
|
|
337
|
+
const remote = remoteSourceFromSetupBody(body);
|
|
338
|
+
sendJson(response, 200, { directories: await listRemoteDirectories(remote) });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (request.method === 'GET' && url.pathname === '/api/jobs') {
|
|
342
|
+
sendJson(response, 200, [...runtime.jobs.values()].map(publicJob).reverse());
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (request.method === 'GET' && url.pathname === '/api/jobs/stream') {
|
|
346
|
+
streamJobs(request, response, runtime);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const jobLog = /^\/api\/jobs\/([a-f0-9]+)\/log$/.exec(url.pathname);
|
|
350
|
+
if (request.method === 'GET' && jobLog) {
|
|
351
|
+
const job = runtime.jobs.get(jobLog[1]);
|
|
352
|
+
if (!job)
|
|
353
|
+
throw new DoxloopError('The requested job does not exist.');
|
|
354
|
+
let log;
|
|
355
|
+
try {
|
|
356
|
+
log = await readFile(join(requireProject(runtime), UI_JOB_LOG_DIRECTORY, `${job.id}.log`), 'utf8');
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
log = job.lines.join('\n');
|
|
360
|
+
}
|
|
361
|
+
send(response, 200, 'text/plain; charset=utf-8', log, baseHeaders());
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const jobAction = /^\/api\/jobs\/([a-f0-9]+)\/(cancel|retry)$/.exec(url.pathname);
|
|
365
|
+
if (request.method === 'POST' && jobAction) {
|
|
366
|
+
const job = runtime.jobs.get(jobAction[1]);
|
|
367
|
+
if (!job)
|
|
368
|
+
throw new DoxloopError('The requested job does not exist.');
|
|
369
|
+
if (jobAction[2] === 'retry') {
|
|
370
|
+
if (job.status === 'running' || !job.retry)
|
|
371
|
+
throw new DoxloopError('This job does not have a safe retry checkpoint.');
|
|
372
|
+
assertNoActiveDocumentationJob(runtime);
|
|
373
|
+
if (job.type.startsWith('plan:'))
|
|
374
|
+
await assertAgentSignedIn(job.retry.agent);
|
|
375
|
+
// A continuation resumes from the plan's own failure record, so it needs
|
|
376
|
+
// no stage reset; the other plan stages restore their durable state first.
|
|
377
|
+
if (job.planId && runtime.root && job.type.startsWith('plan:') && job.type !== 'plan:continue') {
|
|
378
|
+
const stage = job.type === 'plan:generate' ? 'generate' : job.type === 'plan:revise' ? 'revise' : 'propose';
|
|
379
|
+
await retryDocumentationPlan(runtime.root, job.planId, stage);
|
|
380
|
+
}
|
|
381
|
+
const retry = job.retry;
|
|
382
|
+
const next = startCliJob(runtime, job.type, retry.args, retry.cwd, retry.agent, retry.planId);
|
|
383
|
+
noteJob(runtime, next, `Retrying interrupted run ${job.id} from its last durable stage.`);
|
|
384
|
+
sendJson(response, 202, publicJob(next));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (job.status === 'running') {
|
|
388
|
+
job.status = 'cancelled';
|
|
389
|
+
job.finishedAt = new Date().toISOString();
|
|
390
|
+
noteJob(runtime, job, 'The run was cancelled from the Doxloop UI.');
|
|
391
|
+
stopJobChild(job.child);
|
|
392
|
+
if (runtime.root && job.planId)
|
|
393
|
+
await cancelDocumentationPlan(runtime.root, job.planId);
|
|
394
|
+
delete job.child;
|
|
395
|
+
publishJobs(runtime);
|
|
396
|
+
}
|
|
397
|
+
sendJson(response, 200, publicJob(job));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (request.method === 'POST' && url.pathname === '/api/project') {
|
|
401
|
+
await createProjectFromUi(runtime, await readJsonBody(request));
|
|
402
|
+
sendJson(response, 201, await buildUiState(runtime));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (request.method === 'PATCH' && url.pathname === '/api/project') {
|
|
406
|
+
const root = requireProject(runtime);
|
|
407
|
+
await updateProjectFromUi(root, await readJsonBody(request));
|
|
408
|
+
sendJson(response, 200, await buildUiState(runtime));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (request.method === 'POST' && url.pathname === '/api/sources') {
|
|
412
|
+
const root = requireProject(runtime);
|
|
413
|
+
await addSourceFromUi(runtime, root, await readJsonBody(request));
|
|
414
|
+
sendJson(response, 201, await loadProject(root));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (request.method === 'GET' && url.pathname === '/api/source-intelligence') {
|
|
418
|
+
sendJson(response, 200, await buildSourceIntelligence(requireProject(runtime)));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (request.method === 'POST' && url.pathname === '/api/coverage/resolve') {
|
|
422
|
+
const body = recordBody(await readJsonBody(request));
|
|
423
|
+
const action = optionalString(body.action);
|
|
424
|
+
if (!action || !['link', 'exclude', 'needs-human', 'remove-priority', 'reset'].includes(action))
|
|
425
|
+
throw new DoxloopError('Choose a valid coverage resolution action.');
|
|
426
|
+
await resolveCoverageItem(requireProject(runtime), {
|
|
427
|
+
id: stringValue(body.id),
|
|
428
|
+
action: action,
|
|
429
|
+
...(optionalString(body.page) ? { page: optionalString(body.page) } : {}),
|
|
430
|
+
...(optionalString(body.reason) ? { reason: optionalString(body.reason) } : {}),
|
|
431
|
+
});
|
|
432
|
+
sendJson(response, 200, await buildSourceIntelligence(requireProject(runtime)));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const sourceAction = /^\/api\/sources\/([^/]+)$/.exec(url.pathname);
|
|
436
|
+
if (sourceAction && request.method === 'PATCH') {
|
|
437
|
+
const root = requireProject(runtime);
|
|
438
|
+
await updateSourceFromUi(root, decodeURIComponent(sourceAction[1]), await readJsonBody(request));
|
|
439
|
+
sendJson(response, 200, await loadProject(root));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (sourceAction && request.method === 'DELETE') {
|
|
443
|
+
const root = requireProject(runtime);
|
|
444
|
+
await removeSourceFromUi(root, decodeURIComponent(sourceAction[1]));
|
|
445
|
+
sendJson(response, 200, await loadProject(root));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const sourceTest = /^\/api\/sources\/([^/]+)\/test$/.exec(url.pathname);
|
|
449
|
+
if (sourceTest && request.method === 'POST') {
|
|
450
|
+
const root = requireProject(runtime);
|
|
451
|
+
const project = await loadProject(root);
|
|
452
|
+
const source = project.sources.find((item) => item.name === decodeURIComponent(sourceTest[1]));
|
|
453
|
+
if (!source)
|
|
454
|
+
throw new DoxloopError('The requested source does not exist.');
|
|
455
|
+
const remote = source.remote ? await testRemoteSource(source.remote) : undefined;
|
|
456
|
+
sendJson(response, 200, { health: await connectorForSource(source).health(root, source), ...(remote ? { remote } : {}) });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const sourceRefresh = /^\/api\/sources\/([^/]+)\/refresh$/.exec(url.pathname);
|
|
460
|
+
if (sourceRefresh && request.method === 'POST') {
|
|
461
|
+
const root = requireProject(runtime);
|
|
462
|
+
assertNoActiveDocumentationJobs(runtime.jobs.values());
|
|
463
|
+
await refreshDocsSiteSource(runtime, root, decodeURIComponent(sourceRefresh[1]));
|
|
464
|
+
sendJson(response, 200, await loadProject(root));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (request.method === 'POST' && url.pathname === '/api/sync/configure') {
|
|
468
|
+
const root = requireProject(runtime);
|
|
469
|
+
const project = await loadProject(root);
|
|
470
|
+
const sync = syncConfigFromBody(await readJsonBody(request), project.sync);
|
|
471
|
+
sendJson(response, 200, { messages: await applySyncConfig(root, project, sync), sync });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (request.method === 'POST' && url.pathname === '/api/sync/off') {
|
|
475
|
+
const root = requireProject(runtime);
|
|
476
|
+
sendJson(response, 200, { messages: await disableSync(root, await loadProject(root)) });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (request.method === 'POST' && url.pathname === '/api/sync/now') {
|
|
480
|
+
const root = requireProject(runtime);
|
|
481
|
+
sendJson(response, 202, publicJob(startCliJob(runtime, 'sync', ['sync', 'now', '--trigger', 'manual', '--cwd', root], root)));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (request.method === 'GET' && url.pathname === '/api/collections') {
|
|
485
|
+
const root = requireProject(runtime);
|
|
486
|
+
sendJson(response, 200, await documentationCollections(root, await loadProject(root)));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (request.method === 'POST' && url.pathname === '/api/collections') {
|
|
490
|
+
const body = recordBody(await readJsonBody(request));
|
|
491
|
+
sendJson(response, 200, await createDocumentationCollection(requireProject(runtime), { sourceDirectory: rawText(body.sourceDirectory), version: stringValue(body.version), locale: stringValue(body.locale) }));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (request.method === 'GET' && url.pathname === '/api/pages/search') {
|
|
495
|
+
sendJson(response, 200, await searchPageText(requireProject(runtime), url.searchParams.get('q') ?? ''));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (request.method === 'GET' && url.pathname === '/api/comments') {
|
|
499
|
+
sendJson(response, 200, await pageComments(requireProject(runtime)));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (request.method === 'POST' && url.pathname === '/api/comments') {
|
|
503
|
+
const body = recordBody(await readJsonBody(request));
|
|
504
|
+
sendJson(response, 200, await savePageComment(requireProject(runtime), { path: typeof body.resolveId === 'string' ? '' : stringValue(body.path), text: typeof body.resolveId === 'string' ? '' : stringValue(body.text), ...(typeof body.proposalId === 'string' ? { proposalId: body.proposalId } : {}), ...(typeof body.changeId === 'string' ? { changeId: body.changeId } : {}), ...(typeof body.hunkId === 'string' ? { hunkId: body.hunkId } : {}), ...(typeof body.resolveId === 'string' ? { resolveId: body.resolveId } : {}) }));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (request.method === 'PUT' && url.pathname === '/api/pages/bulk-metadata') {
|
|
508
|
+
const body = recordBody(await readJsonBody(request));
|
|
509
|
+
if (!Array.isArray(body.writes))
|
|
510
|
+
throw new DoxloopError('Supply metadata writes for the selected pages.');
|
|
511
|
+
sendJson(response, 200, await updateBulkPageMetadata(requireProject(runtime), body.writes));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (request.method === 'GET' && url.pathname === '/api/audit') {
|
|
515
|
+
sendJson(response, 200, await auditDocumentation(requireProject(runtime)));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (request.method === 'POST' && url.pathname === '/api/audit/backfill') {
|
|
519
|
+
sendJson(response, 200, await backfillEvidence(requireProject(runtime)));
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (url.pathname === '/api/reader-verification' && (request.method === 'GET' || request.method === 'PUT')) {
|
|
523
|
+
const body = request.method === 'PUT' ? recordBody(await readJsonBody(request)) : undefined;
|
|
524
|
+
if (body && typeof body.enabled !== 'boolean')
|
|
525
|
+
throw new DoxloopError('Choose whether to show reader verification.');
|
|
526
|
+
sendJson(response, 200, await readerVerification(requireProject(runtime), body?.enabled));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (request.method === 'GET' && url.pathname === '/api/authoring-estimate') {
|
|
530
|
+
sendJson(response, 200, await authoringEstimate(requireProject(runtime), Math.max(1, Math.min(500, Number(url.searchParams.get('pages')) || 1)), url.searchParams.get('agent') ?? undefined, url.searchParams.get('model') ?? undefined));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (request.method === 'GET' && url.pathname === '/api/pages') {
|
|
534
|
+
sendJson(response, 200, await listDocumentationPages(requireProject(runtime)));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (request.method === 'GET' && url.pathname === '/api/pages/content') {
|
|
538
|
+
const root = requireProject(runtime);
|
|
539
|
+
const path = url.searchParams.get('path')?.trim();
|
|
540
|
+
if (!path)
|
|
541
|
+
throw new DoxloopError('A page content request needs a path.', 2);
|
|
542
|
+
sendJson(response, 200, await readPageContent(root, path));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (request.method === 'PUT' && url.pathname === '/api/pages/content') {
|
|
546
|
+
const body = recordBody(await readJsonBody(request));
|
|
547
|
+
sendJson(response, 200, await savePageContent(requireProject(runtime), { path: stringValue(body.path), content: rawText(body.content), fingerprint: stringValue(body.fingerprint), evidenceDisposition: body.evidenceDisposition === 'preserved' ? 'preserved' : 'needs-review' }));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (request.method === 'POST' && url.pathname === '/api/pages/editor-preview') {
|
|
551
|
+
const body = recordBody(await readJsonBody(request));
|
|
552
|
+
const root = requireProject(runtime);
|
|
553
|
+
const preview = await pageEditorPreview(root, stringValue(body.path), rawText(body.content), stringValue(body.base));
|
|
554
|
+
const token = randomBytes(24).toString('hex');
|
|
555
|
+
const cache = runtime.editorPreviews ??= new Map();
|
|
556
|
+
for (const [key, value] of cache)
|
|
557
|
+
if (Date.now() - value.createdAt > 30 * 60_000)
|
|
558
|
+
cache.delete(key);
|
|
559
|
+
while (cache.size >= 12)
|
|
560
|
+
cache.delete(cache.keys().next().value);
|
|
561
|
+
cache.set(token, { root, html: preview.html, createdAt: Date.now() });
|
|
562
|
+
sendJson(response, 200, { url: `/api/pages/editor-preview/${token}`, blocks: preview.blocks });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (request.method === 'GET' && /^\/api\/pages\/editor-preview\/[a-f0-9]{48}$/.test(url.pathname)) {
|
|
566
|
+
const preview = runtime.editorPreviews?.get(url.pathname.split('/').at(-1));
|
|
567
|
+
if (!preview || preview.root !== requireProject(runtime) || Date.now() - preview.createdAt > 30 * 60_000) {
|
|
568
|
+
send(response, 410, 'text/plain; charset=utf-8', 'This editable preview expired. Switch to source and back to Content to refresh it.', baseHeaders());
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const nonce = randomBytes(18).toString('base64');
|
|
572
|
+
send(response, 200, 'text/html; charset=utf-8', preview.html.replace(/<script(?=[ >])/g, `<script nonce="${nonce}"`), {
|
|
573
|
+
...baseHeaders(),
|
|
574
|
+
'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; img-src data: https: http://127.0.0.1:* http://localhost:*; base-uri http://127.0.0.1:* http://localhost:*; frame-ancestors 'self'; sandbox allow-scripts`,
|
|
575
|
+
});
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (request.method === 'POST' && url.pathname === '/api/pages/preview') {
|
|
579
|
+
sendJson(response, 200, previewPageContent(rawText(recordBody(await readJsonBody(request)).content)));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (request.method === 'POST' && url.pathname === '/api/pages/lifecycle') {
|
|
583
|
+
const body = recordBody(await readJsonBody(request));
|
|
584
|
+
if (!['create', 'rename', 'delete'].includes(String(body.action)))
|
|
585
|
+
throw new DoxloopError('Choose create, rename, or delete.');
|
|
586
|
+
sendJson(response, 200, await changePageLifecycle(requireProject(runtime), {
|
|
587
|
+
action: body.action, path: stringValue(body.path),
|
|
588
|
+
...(typeof body.fingerprint === 'string' ? { fingerprint: body.fingerprint } : {}),
|
|
589
|
+
...(typeof body.to === 'string' ? { to: body.to } : {}),
|
|
590
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
591
|
+
...(typeof body.content === 'string' ? { content: body.content } : {}),
|
|
592
|
+
...(typeof body.replacement === 'string' && body.replacement ? { replacement: body.replacement } : {}),
|
|
593
|
+
}));
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (request.method === 'GET' && url.pathname === '/api/direct-edits') {
|
|
597
|
+
sendJson(response, 200, await listDirectEdits(requireProject(runtime)));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const directUndo = /^\/api\/direct-edits\/([a-f0-9-]{36})\/undo$/.exec(url.pathname);
|
|
601
|
+
if (request.method === 'POST' && directUndo) {
|
|
602
|
+
sendJson(response, 200, await undoDirectEdit(requireProject(runtime), directUndo[1]));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (request.method === 'POST' && url.pathname === '/api/pages/edit') {
|
|
606
|
+
assertNoActiveDocumentationJob(runtime);
|
|
607
|
+
const root = requireProject(runtime);
|
|
608
|
+
const project = await loadProject(root);
|
|
609
|
+
const runId = createRunId();
|
|
610
|
+
const spec = pageEditJobSpec(root, project, runId, await readJsonBody(request));
|
|
611
|
+
await resolveEditScope(root, project, spec.paths, spec.allowRelated);
|
|
612
|
+
const job = startCliJob(runtime, spec.type, spec.args, root, spec.agent);
|
|
613
|
+
sendJson(response, 202, { job: publicJob(job) });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (request.method === 'GET' && url.pathname === '/api/pages/metadata') {
|
|
617
|
+
sendJson(response, 200, await readPageMetadata(requireProject(runtime), url.searchParams.get('path')));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (request.method === 'PUT' && url.pathname === '/api/pages/metadata') {
|
|
621
|
+
assertNoActiveDocumentationJob(runtime);
|
|
622
|
+
sendJson(response, 200, await updatePageMetadata(requireProject(runtime), await readJsonBody(request)));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (request.method === 'GET' && url.pathname === '/api/navigation') {
|
|
626
|
+
sendJson(response, 200, await readNavigation(requireProject(runtime)));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (request.method === 'PUT' && url.pathname === '/api/navigation') {
|
|
630
|
+
assertNoActiveDocumentationJob(runtime);
|
|
631
|
+
sendJson(response, 200, await writeNavigation(requireProject(runtime), await readJsonBody(request)));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (request.method === 'GET' && url.pathname === '/api/branding') {
|
|
635
|
+
sendJson(response, 200, await readBranding(requireProject(runtime)));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (request.method === 'PUT' && url.pathname === '/api/branding') {
|
|
639
|
+
assertNoActiveDocumentationJob(runtime);
|
|
640
|
+
sendJson(response, 200, await writeBranding(requireProject(runtime), await readJsonBody(request)));
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (request.method === 'GET' && url.pathname === '/api/assets') {
|
|
644
|
+
sendJson(response, 200, await readAssetLibrary(requireProject(runtime)));
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (request.method === 'GET' && url.pathname === '/api/assets/file') {
|
|
648
|
+
const root = requireProject(runtime);
|
|
649
|
+
const path = url.searchParams.get('path') ?? '';
|
|
650
|
+
const file = assertInside(root, resolve(root, path));
|
|
651
|
+
const extension = extname(file).toLowerCase();
|
|
652
|
+
if (!path || path.split('/').some((part) => part === '..' || part === '.doxloop' || part === 'node_modules') || (!ASSET_IMAGE_EXTENSIONS.has(extension) && !ASSET_FILE_EXTENSIONS.has(extension)) || !(await pathExists(file))) {
|
|
653
|
+
sendJson(response, 404, { error: 'That asset does not exist.' });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
send(response, 200, mimeType(file), await readFile(file), { ...baseHeaders(), 'Cache-Control': 'no-store', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'" });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (request.method === 'POST' && url.pathname === '/api/assets') {
|
|
660
|
+
assertNoActiveDocumentationJob(runtime);
|
|
661
|
+
sendJson(response, 201, await uploadAsset(requireProject(runtime), await readJsonBody(request, MAX_ASSET_BODY_BYTES)));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (request.method === 'POST' && url.pathname === '/api/assets/delete') {
|
|
665
|
+
assertNoActiveDocumentationJob(runtime);
|
|
666
|
+
sendJson(response, 200, await deleteAsset(requireProject(runtime), await readJsonBody(request)));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (request.method === 'POST' && url.pathname === '/api/assets/alt') {
|
|
670
|
+
assertNoActiveDocumentationJob(runtime);
|
|
671
|
+
sendJson(response, 200, await updateAssetAlt(requireProject(runtime), await readJsonBody(request)));
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (request.method === 'POST' && url.pathname === '/api/captures/replace') {
|
|
675
|
+
sendJson(response, 200, await replaceRunCapture(requireProject(runtime), await readJsonBody(request, MAX_ASSET_BODY_BYTES)));
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (request.method === 'GET' && url.pathname === '/api/glossary') {
|
|
679
|
+
sendJson(response, 200, await readGlossary(requireProject(runtime)));
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (request.method === 'POST' && url.pathname === '/api/glossary/generate') {
|
|
683
|
+
assertNoActiveDocumentationJob(runtime);
|
|
684
|
+
sendJson(response, 200, await generateGlossaryPage(requireProject(runtime), await readJsonBody(request)));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (request.method === 'GET' && url.pathname === '/api/sources/git') {
|
|
688
|
+
const root = requireProject(runtime);
|
|
689
|
+
sendJson(response, 200, { sources: (await gitBackedSources(root, await loadProject(root))).map((source) => source.name) });
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (request.method === 'GET' && url.pathname === '/api/sources/refs') {
|
|
693
|
+
sendJson(response, 200, await listSourceRefs(requireProject(runtime), url.searchParams.get('source') ?? ''));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (request.method === 'GET' && url.pathname === '/api/history') {
|
|
697
|
+
const root = requireProject(runtime);
|
|
698
|
+
await backfillHistory(root);
|
|
699
|
+
const path = url.searchParams.get('page');
|
|
700
|
+
const limit = historyLimit(url.searchParams.get('limit'));
|
|
701
|
+
const available = await historyAvailable();
|
|
702
|
+
if (path) {
|
|
703
|
+
sendJson(response, 200, { available, entries: await pageHistory(root, path, limit) });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const requests = await listRequests(root, limit);
|
|
707
|
+
const pages = await requestPages(root, requests.map((entry) => entry.id));
|
|
708
|
+
sendJson(response, 200, {
|
|
709
|
+
available,
|
|
710
|
+
requests: requests.map((entry) => ({ ...entry, pages: pages[entry.id] ?? [] })),
|
|
711
|
+
});
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (request.method === 'GET' && url.pathname === '/api/history/deployments') {
|
|
715
|
+
const root = requireProject(runtime);
|
|
716
|
+
await backfillHistory(root);
|
|
717
|
+
sendJson(response, 200, {
|
|
718
|
+
available: await historyAvailable(),
|
|
719
|
+
deployments: await listDeployments(root, historyLimit(url.searchParams.get('limit'))),
|
|
720
|
+
});
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (request.method === 'GET' && url.pathname === '/api/deployment/site') {
|
|
724
|
+
sendJson(response, 200, { url: await deployedSiteUrl(requireProject(runtime)) });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (request.method === 'GET' && url.pathname === '/api/deployment/credentials') {
|
|
728
|
+
sendJson(response, 200, await deployCredentialState());
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (request.method === 'POST' && url.pathname === '/api/deployment/credentials') {
|
|
732
|
+
const body = recordBody(await readJsonBody(request));
|
|
733
|
+
const target = stringValue(body.target);
|
|
734
|
+
if (target !== 'netlify' && target !== 'vercel')
|
|
735
|
+
throw new DoxloopError('Credentials can be saved for Netlify or Vercel.');
|
|
736
|
+
await saveDeployCredential(target, stringValue(body.token));
|
|
737
|
+
sendJson(response, 200, { saved: true, target });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (request.method === 'GET' && url.pathname === '/api/proposals') {
|
|
741
|
+
sendJson(response, 200, (await listSyncRuns(requireProject(runtime))).map(uiProposal));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (request.method === 'GET' && url.pathname === '/api/plans/discovery') {
|
|
745
|
+
const discovery = await discoverDocumentationSources(requireProject(runtime));
|
|
746
|
+
sendJson(response, 200, {
|
|
747
|
+
suggestedPages: discovery.inventory.suggestedPages,
|
|
748
|
+
publicSignals: discovery.inventory.totals.publicSignals,
|
|
749
|
+
generatedAt: discovery.inventory.generatedAt,
|
|
750
|
+
cacheHit: discovery.cacheHit,
|
|
751
|
+
});
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (request.method === 'GET' && url.pathname === '/api/application/readiness') {
|
|
755
|
+
const root = requireProject(runtime);
|
|
756
|
+
const project = await loadProject(root);
|
|
757
|
+
sendJson(response, 200, await checkApplicationReadiness(project.application, await captureAuthContext(root), readinessProbeOptions('project')));
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (request.method === 'POST' && url.pathname === '/api/application/readiness') {
|
|
761
|
+
const root = requireProject(runtime);
|
|
762
|
+
const project = await loadProject(root);
|
|
763
|
+
const application = applicationFromBody(await readJsonBody(request), project);
|
|
764
|
+
sendJson(response, 200, await checkApplicationReadiness(application, await captureAuthContext(root), readinessProbeOptions('project')));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/application/readiness') {
|
|
768
|
+
const body = recordBody(await readJsonBody(request));
|
|
769
|
+
const application = applicationFromBody(body);
|
|
770
|
+
// Setup has no project yet: the session recorded in this wizard and the
|
|
771
|
+
// credentials typed into it stand in for the stored ones.
|
|
772
|
+
sendJson(response, 200, await checkApplicationReadiness(application, {
|
|
773
|
+
...(runtime.pendingCaptureSession ? { session: runtime.pendingCaptureSession.state } : {}),
|
|
774
|
+
credentials: body.hasCredentials === true,
|
|
775
|
+
}, readinessProbeOptions('setup')));
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (request.method === 'GET' && url.pathname === '/api/application/auth') {
|
|
779
|
+
sendJson(response, 200, await captureAuthState(runtime, requireProject(runtime)));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (request.method === 'PUT' && url.pathname === '/api/application/credentials') {
|
|
783
|
+
const root = requireProject(runtime);
|
|
784
|
+
const body = recordBody(await readJsonBody(request));
|
|
785
|
+
await saveCaptureCredentials(root, { username: stringValue(body.username), password: typeof body.password === 'string' ? body.password : '' });
|
|
786
|
+
sendJson(response, 200, await captureAuthState(runtime, root));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (request.method === 'DELETE' && url.pathname === '/api/application/credentials') {
|
|
790
|
+
const root = requireProject(runtime);
|
|
791
|
+
await removeCaptureCredentials(root);
|
|
792
|
+
sendJson(response, 200, await captureAuthState(runtime, root));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (request.method === 'DELETE' && url.pathname === '/api/application/session') {
|
|
796
|
+
const root = requireProject(runtime);
|
|
797
|
+
await removeCaptureSession(root);
|
|
798
|
+
sendJson(response, 200, await captureAuthState(runtime, root));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (request.method === 'POST' && url.pathname === '/api/application/sign-in') {
|
|
802
|
+
const root = requireProject(runtime);
|
|
803
|
+
const project = await loadProject(root);
|
|
804
|
+
const body = await readJsonBody(request);
|
|
805
|
+
// The form may hold an unsaved URL; signing in against it is still useful.
|
|
806
|
+
const application = body && typeof body === 'object' && 'baseUrl' in body
|
|
807
|
+
? applicationFromBody(body, project)
|
|
808
|
+
: project.application;
|
|
809
|
+
if (!application)
|
|
810
|
+
throw new DoxloopError('Configure the application base URL before signing in.');
|
|
811
|
+
await beginCaptureSignIn(runtime, application, 'project');
|
|
812
|
+
sendJson(response, 200, await captureAuthState(runtime, root));
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (request.method === 'POST' && url.pathname === '/api/application/sign-in/finish') {
|
|
816
|
+
const root = requireProject(runtime);
|
|
817
|
+
const active = runtime.captureSignIn;
|
|
818
|
+
if (!active || active.scope !== 'project')
|
|
819
|
+
throw new DoxloopError('No browser sign-in is in progress. Choose "Sign in with browser" first.');
|
|
820
|
+
const state = await active.session.finish();
|
|
821
|
+
runtime.captureSignIn = undefined;
|
|
822
|
+
await saveCaptureSession(root, active.origin, state);
|
|
823
|
+
sendJson(response, 200, await captureAuthState(runtime, root));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (request.method === 'POST' && url.pathname === '/api/application/sign-in/cancel') {
|
|
827
|
+
const root = requireProject(runtime);
|
|
828
|
+
await cancelCaptureSignIn(runtime);
|
|
829
|
+
sendJson(response, 200, await captureAuthState(runtime, root));
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/application/saved-sign-ins') {
|
|
833
|
+
const body = recordBody(await readJsonBody(request));
|
|
834
|
+
sendJson(response, 200, { matches: await reusableSignIns(optionalString(body.baseUrl) ?? '') });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (request.method === 'GET' && url.pathname === '/api/setup/application/auth') {
|
|
838
|
+
sendJson(response, 200, setupCaptureAuthState(runtime));
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/application/sign-in') {
|
|
842
|
+
const application = applicationFromBody(await readJsonBody(request));
|
|
843
|
+
await beginCaptureSignIn(runtime, application, 'setup');
|
|
844
|
+
sendJson(response, 200, setupCaptureAuthState(runtime));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/application/sign-in/finish') {
|
|
848
|
+
const active = runtime.captureSignIn;
|
|
849
|
+
if (!active || active.scope !== 'setup')
|
|
850
|
+
throw new DoxloopError('No browser sign-in is in progress. Choose "Sign in with browser" first.');
|
|
851
|
+
const state = await active.session.finish();
|
|
852
|
+
runtime.captureSignIn = undefined;
|
|
853
|
+
if (state.cookies.length === 0 && state.origins.every((item) => item.localStorage.length === 0)) {
|
|
854
|
+
throw new DoxloopError('The browser session holds no cookies or local storage yet. Complete the sign-in in the Chrome window before saving.');
|
|
855
|
+
}
|
|
856
|
+
runtime.pendingCaptureSession = { origin: active.origin, savedAt: new Date().toISOString(), state };
|
|
857
|
+
sendJson(response, 200, setupCaptureAuthState(runtime));
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/application/sign-in/cancel') {
|
|
861
|
+
await cancelCaptureSignIn(runtime);
|
|
862
|
+
sendJson(response, 200, setupCaptureAuthState(runtime));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (request.method === 'DELETE' && url.pathname === '/api/setup/application/session') {
|
|
866
|
+
runtime.pendingCaptureSession = undefined;
|
|
867
|
+
sendJson(response, 200, setupCaptureAuthState(runtime));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (request.method === 'POST' && url.pathname === '/api/setup/generator/preflight') {
|
|
871
|
+
const body = recordBody(await readJsonBody(request));
|
|
872
|
+
const generator = parseGenerator(optionalString(body.generator)) ?? 'doxbrix';
|
|
873
|
+
sendJson(response, 200, await generatorPreflight(generator));
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (request.method === 'POST' && url.pathname === '/api/plans') {
|
|
877
|
+
assertNoActiveDocumentationJob(runtime);
|
|
878
|
+
const root = requireProject(runtime);
|
|
879
|
+
const body = recordBody(await readJsonBody(request));
|
|
880
|
+
const mode = optionalString(body.mode);
|
|
881
|
+
if (mode !== 'create' && mode !== 'update')
|
|
882
|
+
throw new DoxloopError('Plan mode must be create or update.');
|
|
883
|
+
const scope = optionalString(body.scope) ?? 'standard';
|
|
884
|
+
if (!['starter', 'standard', 'comprehensive', 'custom'].includes(scope)) {
|
|
885
|
+
throw new DoxloopError('Plan scope must be starter, standard, comprehensive, or custom.');
|
|
886
|
+
}
|
|
887
|
+
const project = await loadProject(root);
|
|
888
|
+
const template = releaseTemplateFromBody(body.template);
|
|
889
|
+
const requestedAgent = parseAgent(optionalString(body.agent));
|
|
890
|
+
const clarificationMode = optionalString(body.clarificationMode) ?? 'review';
|
|
891
|
+
if (!['review', 'defaults', 'stop'].includes(clarificationMode))
|
|
892
|
+
throw new DoxloopError('Clarification mode must be review, defaults, or stop.');
|
|
893
|
+
const screenshotIntent = normalizeScreenshotIntent(body.screenshots);
|
|
894
|
+
await assertScreenshotPlanningReadiness(project.application, screenshotIntent, await captureAuthContext(root));
|
|
895
|
+
if (screenshotIntent === 'enabled') {
|
|
896
|
+
const browserReadiness = await checkScreenCaptureBrowser();
|
|
897
|
+
if (!browserReadiness.available)
|
|
898
|
+
throw new DoxloopError(`Screenshots are selected, but the capture browser cannot start. ${browserReadiness.message}`);
|
|
899
|
+
}
|
|
900
|
+
const targetPages = body.targetPages === undefined || body.targetPages === null || body.targetPages === '' ? undefined : Number(body.targetPages);
|
|
901
|
+
if (targetPages !== undefined && (!Number.isInteger(targetPages) || targetPages < 1 || targetPages > 500)) {
|
|
902
|
+
throw new DoxloopError('Target page count must be a whole number between 1 and 500.');
|
|
903
|
+
}
|
|
904
|
+
// A signed-out assistant fails every planning session within a second;
|
|
905
|
+
// say so before a plan is created rather than after it fails.
|
|
906
|
+
await assertAgentSignedIn(requestedAgent ?? project.defaultAgent);
|
|
907
|
+
const plan = await createDocumentationPlan(root, {
|
|
908
|
+
mode,
|
|
909
|
+
scope: scope,
|
|
910
|
+
...(targetPages !== undefined ? { targetPages } : {}),
|
|
911
|
+
...(template ? { template } : {}),
|
|
912
|
+
...(optionalString(body.request) ? { request: optionalString(body.request) } : {}),
|
|
913
|
+
clarificationMode: clarificationMode,
|
|
914
|
+
execution: {
|
|
915
|
+
...(requestedAgent ?? project.defaultAgent ? { agent: (requestedAgent ?? project.defaultAgent) } : {}),
|
|
916
|
+
...(optionalString(body.model) ?? projectDefaultModel(project, requestedAgent ?? project.defaultAgent) ? { model: (optionalString(body.model) ?? projectDefaultModel(project, requestedAgent ?? project.defaultAgent)) } : {}),
|
|
917
|
+
...(optionalString(body.reasoning) ? { reasoning: parseReasoning(optionalString(body.reasoning)) } : {}),
|
|
918
|
+
...(optionalString(body.effort) ? { effort: parseClaudeEffort(optionalString(body.effort)) } : {}),
|
|
919
|
+
screenshots: screenshotIntent,
|
|
920
|
+
limits: batchLimits(body.limits, defaultBatchLimits(scope, screenshotIntent !== 'disabled', targetPages)),
|
|
921
|
+
},
|
|
922
|
+
});
|
|
923
|
+
const job = startCliJob(runtime, 'plan:propose', ['plan', 'propose', '--id', plan.id, '--cwd', root], root, requestedAgent ?? project.defaultAgent, plan.id);
|
|
924
|
+
sendJson(response, 202, { plan, job: publicJob(job) });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const planRoute = /^\/api\/plans\/(plan-[a-z0-9-]+)$/.exec(url.pathname);
|
|
928
|
+
if (request.method === 'GET' && planRoute) {
|
|
929
|
+
sendJson(response, 200, await readDocumentationPlan(requireProject(runtime), planRoute[1]));
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (request.method === 'PATCH' && planRoute) {
|
|
933
|
+
sendJson(response, 200, await editDocumentationPlan(requireProject(runtime), planRoute[1], await readJsonBody(request)));
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const planVersionsRoute = /^\/api\/plans\/(plan-[a-z0-9-]+)\/versions$/.exec(url.pathname);
|
|
937
|
+
if (request.method === 'GET' && planVersionsRoute) {
|
|
938
|
+
sendJson(response, 200, await listDocumentationPlanVersions(requireProject(runtime), planVersionsRoute[1]));
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const planAction = /^\/api\/plans\/(plan-[a-z0-9-]+)\/(revise|clarify|approve|generate|resume|continue|retry)$/.exec(url.pathname);
|
|
942
|
+
if (request.method === 'POST' && planAction) {
|
|
943
|
+
const root = requireProject(runtime);
|
|
944
|
+
const id = planAction[1];
|
|
945
|
+
const action = planAction[2];
|
|
946
|
+
const actionBody = recordBody(await readJsonBody(request));
|
|
947
|
+
if (action === 'retry') {
|
|
948
|
+
// A plan that never got past planning has no pages to approve, so the
|
|
949
|
+
// approval rules do not apply; the planning stage simply runs again.
|
|
950
|
+
const failedPlan = await readDocumentationPlan(root, id);
|
|
951
|
+
if (failedPlan.status !== 'failed' || failedPlan.failure?.stage !== 'propose') {
|
|
952
|
+
throw new DoxloopError('Only a plan whose planning stage failed can be retried here. Retry generation from the plan review, or ask the agent to revise the plan.');
|
|
953
|
+
}
|
|
954
|
+
assertNoActiveDocumentationJob(runtime);
|
|
955
|
+
// The body may switch the assistant; a signed-out pinned assistant gives
|
|
956
|
+
// way to the project's default one.
|
|
957
|
+
const prepared = await preparePlanRunAgent(root, failedPlan, actionBody);
|
|
958
|
+
await retryDocumentationPlan(root, id, 'propose');
|
|
959
|
+
const job = startCliJob(runtime, 'plan:propose', ['plan', 'propose', '--id', id, '--cwd', root], root, prepared.plan.execution.agent, id);
|
|
960
|
+
if (prepared.note)
|
|
961
|
+
noteJob(runtime, job, prepared.note);
|
|
962
|
+
sendJson(response, 202, publicJob(job));
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (action === 'approve') {
|
|
966
|
+
sendJson(response, 200, await approveDocumentationPlan(root, id));
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (action === 'resume') {
|
|
970
|
+
sendJson(response, 200, await resumeDocumentationPlan(root, id));
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
let plan = await readDocumentationPlan(root, id);
|
|
974
|
+
if (action === 'continue') {
|
|
975
|
+
// Pick up a failed run where it stopped instead of starting over: a
|
|
976
|
+
// planning failure keeps the plan the agent proposed; a generation
|
|
977
|
+
// failure keeps the pages and screenshots already in the workspace.
|
|
978
|
+
const strategy = optionalString(actionBody.strategy) ?? 'resume';
|
|
979
|
+
if (strategy !== 'resume' && strategy !== 'ignore-errors')
|
|
980
|
+
throw new DoxloopError('Continuation strategy must be resume or ignore-errors.');
|
|
981
|
+
// The job may complete after the browser rendered failed-plan actions.
|
|
982
|
+
// Return the completed plan instead of turning that stale click into an
|
|
983
|
+
// error; the normal action reload will then replace the stale screen.
|
|
984
|
+
if (plan.status === 'generated') {
|
|
985
|
+
sendJson(response, 200, { plan });
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
assertNoActiveDocumentationJob(runtime);
|
|
989
|
+
if (plan.failure?.stage !== 'generate') {
|
|
990
|
+
if (strategy !== 'ignore-errors')
|
|
991
|
+
throw new DoxloopError('A planning run cannot be resumed. Retry planning, or continue with the plan it left behind.');
|
|
992
|
+
sendJson(response, 200, { plan: await ignoreDocumentationPlanError(root, id) });
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const prepared = await preparePlanRunAgent(root, plan, actionBody);
|
|
996
|
+
const job = startCliJob(runtime, 'plan:continue', ['plan', 'continue', '--id', id, '--strategy', strategy, '--cwd', root], root, prepared.plan.execution.agent, id);
|
|
997
|
+
if (prepared.note)
|
|
998
|
+
noteJob(runtime, job, prepared.note);
|
|
999
|
+
sendJson(response, 202, { job: publicJob(job) });
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
assertNoActiveDocumentationJob(runtime);
|
|
1003
|
+
if (action === 'clarify') {
|
|
1004
|
+
await assertAgentSignedIn(plan.execution.agent);
|
|
1005
|
+
const body = actionBody;
|
|
1006
|
+
const answers = stringRecord(body.answers);
|
|
1007
|
+
const useRecommendations = body.useRecommendations === true;
|
|
1008
|
+
const feedback = documentationPlanClarificationFeedback(plan, answers, useRecommendations);
|
|
1009
|
+
const effectiveAnswers = Object.fromEntries(plan.questions.map((question) => [question.id, answers[question.id] ?? question.recommendation ?? '']));
|
|
1010
|
+
await beginDocumentationPlanRevision(root, id, effectiveAnswers);
|
|
1011
|
+
const job = startCliJob(runtime, 'plan:revise', ['plan', 'revise', '--id', id, '--feedback', feedback, '--cwd', root], root, plan.execution.agent, id);
|
|
1012
|
+
sendJson(response, 202, publicJob(job));
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (action === 'revise') {
|
|
1016
|
+
const feedback = optionalString(actionBody.feedback);
|
|
1017
|
+
if (!feedback)
|
|
1018
|
+
throw new DoxloopError('Describe how the documentation plan should change.');
|
|
1019
|
+
await assertAgentSignedIn(plan.execution.agent);
|
|
1020
|
+
await beginDocumentationPlanRevision(root, id);
|
|
1021
|
+
const job = startCliJob(runtime, 'plan:revise', ['plan', 'revise', '--id', id, '--feedback', feedback, '--cwd', root], root, plan.execution.agent, id);
|
|
1022
|
+
sendJson(response, 202, publicJob(job));
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const prepared = await preparePlanRunAgent(root, plan, actionBody);
|
|
1026
|
+
plan = prepared.plan;
|
|
1027
|
+
const job = startCliJob(runtime, 'plan:generate', ['plan', 'generate', '--id', id, '--cwd', root], root, plan.execution.agent, id);
|
|
1028
|
+
if (prepared.note)
|
|
1029
|
+
noteJob(runtime, job, prepared.note);
|
|
1030
|
+
sendJson(response, 202, publicJob(job));
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const proposal = /^\/api\/proposals\/([a-z0-9-]+)$/.exec(url.pathname);
|
|
1034
|
+
if (request.method === 'GET' && proposal) {
|
|
1035
|
+
sendJson(response, 200, await readSyncRun(requireProject(runtime), proposal[1]));
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const proposalDelivery = /^\/api\/proposals\/([a-z0-9-]+)\/delivery\/branch$/.exec(url.pathname);
|
|
1039
|
+
if (request.method === 'POST' && proposalDelivery) {
|
|
1040
|
+
const body = recordBody(await readJsonBody(request));
|
|
1041
|
+
sendJson(response, 201, await createProposalBranch(requireProject(runtime), proposalDelivery[1], optionalString(body.branch)));
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const proposalPublish = /^\/api\/proposals\/([a-z0-9-]+)\/delivery\/publish$/.exec(url.pathname);
|
|
1045
|
+
if (request.method === 'POST' && proposalPublish) {
|
|
1046
|
+
const body = recordBody(await readJsonBody(request));
|
|
1047
|
+
sendJson(response, 200, await publishProposalBranch(requireProject(runtime), proposalPublish[1], body.createPullRequest === true));
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const proposalPreview = /^\/api\/proposals\/([a-z0-9-]+)\/preview\/start$/.exec(url.pathname);
|
|
1051
|
+
if (request.method === 'POST' && proposalPreview) {
|
|
1052
|
+
const body = recordBody(await readJsonBody(request));
|
|
1053
|
+
const open = body.open === true;
|
|
1054
|
+
const root = requireProject(runtime);
|
|
1055
|
+
const project = await loadProject(root);
|
|
1056
|
+
const run = await readSyncRun(root, proposalPreview[1]);
|
|
1057
|
+
const existing = runtime.proposalPreviewJobId
|
|
1058
|
+
? runtime.jobs.get(runtime.proposalPreviewJobId)
|
|
1059
|
+
: undefined;
|
|
1060
|
+
if (existing?.status === 'running' && runtime.proposalPreviewRunId === run.id && runtime.proposalPreviewUrl) {
|
|
1061
|
+
await waitForPreviewServer(runtime.proposalPreviewUrl, existing, projectPreviewIdentity(project.generator, join(root, '.doxloop', 'runs', run.id, 'workspace')));
|
|
1062
|
+
if (open)
|
|
1063
|
+
openBrowser(runtime.proposalPreviewUrl);
|
|
1064
|
+
sendJson(response, 200, { job: publicJob(existing), url: runtime.proposalPreviewUrl });
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (existing?.status === 'running') {
|
|
1068
|
+
existing.status = 'cancelled';
|
|
1069
|
+
existing.finishedAt = new Date().toISOString();
|
|
1070
|
+
const child = existing.child;
|
|
1071
|
+
child?.kill('SIGTERM');
|
|
1072
|
+
if (child)
|
|
1073
|
+
await new Promise((resolveExit) => child.once('exit', () => resolveExit()));
|
|
1074
|
+
}
|
|
1075
|
+
const workspace = join(root, '.doxloop', 'runs', run.id, 'workspace');
|
|
1076
|
+
const previewPort = await availableLocalPort();
|
|
1077
|
+
const previewUrl = `http://127.0.0.1:${previewPort}`;
|
|
1078
|
+
const args = ['preview', '--host', '127.0.0.1', '--port', String(previewPort), '--cwd', workspace];
|
|
1079
|
+
const job = startCliJob(runtime, `proposal-preview:${run.id}`, args, workspace);
|
|
1080
|
+
runtime.proposalPreviewJobId = job.id;
|
|
1081
|
+
runtime.proposalPreviewRunId = run.id;
|
|
1082
|
+
runtime.proposalPreviewUrl = previewUrl;
|
|
1083
|
+
await waitForPreviewServer(previewUrl, job, projectPreviewIdentity(project.generator, workspace));
|
|
1084
|
+
if (open)
|
|
1085
|
+
openBrowser(previewUrl);
|
|
1086
|
+
sendJson(response, 202, { job: publicJob(job), url: previewUrl });
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const proposalDiff = /^\/api\/proposals\/([a-z0-9-]+)\/changes\/(change-\d+)\/diff$/.exec(url.pathname);
|
|
1090
|
+
if (request.method === 'GET' && proposalDiff) {
|
|
1091
|
+
const root = requireProject(runtime);
|
|
1092
|
+
const run = await readSyncRun(root, proposalDiff[1]);
|
|
1093
|
+
sendJson(response, 200, await syncReviewSourceDiff(root, run, requireSyncReviewChange(run, proposalDiff[2])));
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const proposalContent = /^\/api\/proposals\/([a-z0-9-]+)\/changes\/(change-\d+)\/content$/.exec(url.pathname);
|
|
1097
|
+
if (request.method === 'GET' && proposalContent) {
|
|
1098
|
+
sendJson(response, 200, await readSyncRunChangeContent(requireProject(runtime), proposalContent[1], proposalContent[2]));
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (request.method === 'PATCH' && proposalContent) {
|
|
1102
|
+
const body = recordBody(await readJsonBody(request));
|
|
1103
|
+
if (typeof body.fingerprint !== 'string' || !body.fingerprint)
|
|
1104
|
+
throw new DoxloopError('Reload the proposal before editing; a fingerprint is required.');
|
|
1105
|
+
const disposition = body.evidenceDisposition === 'preserved' ? 'preserved' : 'needs-review';
|
|
1106
|
+
sendJson(response, 200, await editSyncRunChange(requireProject(runtime), proposalContent[1], proposalContent[2], stringValue(body.content), disposition, body.fingerprint));
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
const proposalRevision = /^\/api\/proposals\/([a-z0-9-]+)\/(revise|regenerate)$/.exec(url.pathname);
|
|
1110
|
+
if (request.method === 'POST' && proposalRevision) {
|
|
1111
|
+
assertNoActiveDocumentationJob(runtime);
|
|
1112
|
+
const root = requireProject(runtime);
|
|
1113
|
+
const run = await readSyncRun(root, proposalRevision[1]);
|
|
1114
|
+
const body = recordBody(await readJsonBody(request));
|
|
1115
|
+
const changeIds = proposalRevision[2] === 'regenerate'
|
|
1116
|
+
? run.changes.map((change) => change.id)
|
|
1117
|
+
: stringArray(body.changeIds);
|
|
1118
|
+
const instruction = proposalRevision[2] === 'regenerate'
|
|
1119
|
+
? optionalString(body.instruction) ?? 'Regenerate this proposal using the current approved plan and evidence.'
|
|
1120
|
+
: optionalString(body.instruction) ?? '';
|
|
1121
|
+
if (!instruction)
|
|
1122
|
+
throw new DoxloopError('Describe how the selected documentation should change.');
|
|
1123
|
+
if (changeIds.length === 0)
|
|
1124
|
+
throw new DoxloopError('Select at least one file to revise.');
|
|
1125
|
+
const args = ['proposal', 'revise', '--id', run.id, '--request', instruction, '--cwd', root];
|
|
1126
|
+
for (const changeId of changeIds)
|
|
1127
|
+
args.push('--change', changeId);
|
|
1128
|
+
for (const hunkId of stringArray(body.hunkIds))
|
|
1129
|
+
args.push('--hunk', hunkId);
|
|
1130
|
+
const job = startCliJob(runtime, `proposal:revise:${run.id}`, args, root);
|
|
1131
|
+
sendJson(response, 202, publicJob(job));
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const proposalRefine = /^\/api\/proposals\/([a-z0-9-]+)\/refine$/.exec(url.pathname);
|
|
1135
|
+
if (request.method === 'POST' && proposalRefine) {
|
|
1136
|
+
assertNoActiveDocumentationJob(runtime);
|
|
1137
|
+
const root = requireProject(runtime);
|
|
1138
|
+
const run = await readSyncRun(root, proposalRefine[1]);
|
|
1139
|
+
const spec = pageRefineJobSpec(root, run, await readJsonBody(request));
|
|
1140
|
+
const job = startCliJob(runtime, spec.type, spec.args, root);
|
|
1141
|
+
sendJson(response, 202, { job: publicJob(job) });
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const proposalLifecycle = /^\/api\/proposals\/([a-z0-9-]+)\/(undo|archive|recover|repair)$/.exec(url.pathname);
|
|
1145
|
+
if (request.method === 'POST' && proposalLifecycle) {
|
|
1146
|
+
const root = requireProject(runtime);
|
|
1147
|
+
const result = proposalLifecycle[2] === 'undo'
|
|
1148
|
+
? await undoSyncRun(root, proposalLifecycle[1])
|
|
1149
|
+
: proposalLifecycle[2] === 'recover'
|
|
1150
|
+
? await recoverSyncRun(root, proposalLifecycle[1], {
|
|
1151
|
+
ignoreScreenshotProblems: recordBody(await readJsonBody(request)).ignoreScreenshotProblems === true,
|
|
1152
|
+
})
|
|
1153
|
+
: proposalLifecycle[2] === 'repair'
|
|
1154
|
+
? await recoverSyncRun(root, proposalLifecycle[1], { repair: true })
|
|
1155
|
+
: await archiveSyncRun(root, proposalLifecycle[1]);
|
|
1156
|
+
sendJson(response, 200, result);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
const proposalResume = /^\/api\/proposals\/([a-z0-9-]+)\/resume$/.exec(url.pathname);
|
|
1160
|
+
if (request.method === 'POST' && proposalResume) {
|
|
1161
|
+
assertNoActiveDocumentationJob(runtime);
|
|
1162
|
+
const root = requireProject(runtime);
|
|
1163
|
+
const run = await readSyncRun(root, proposalResume[1]);
|
|
1164
|
+
if (run.status !== 'failed' && run.status !== 'generating')
|
|
1165
|
+
throw new DoxloopError(`Only a failed or interrupted proposal can be resumed; ${run.id} is ${run.status}.`);
|
|
1166
|
+
const job = startCliJob(runtime, `proposal:resume:${run.id}`, ['proposal', 'resume', '--id', run.id, '--cwd', root], root);
|
|
1167
|
+
sendJson(response, 202, publicJob(job));
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
// Captured screenshots are served straight from the isolated run workspace so
|
|
1171
|
+
// reviewers can watch them appear during a run, before anything is applied.
|
|
1172
|
+
if (request.method === 'GET' && url.pathname === '/api/captures') {
|
|
1173
|
+
const root = requireProject(runtime);
|
|
1174
|
+
const plan = url.searchParams.get('plan');
|
|
1175
|
+
sendJson(response, 200, { captures: plan ? await listPlanCaptures(root, plan) : await listRunCaptures(root, url.searchParams.get('run') ?? undefined) });
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (request.method === 'GET' && url.pathname === '/api/captures/file') {
|
|
1179
|
+
const root = requireProject(runtime);
|
|
1180
|
+
const runId = url.searchParams.get('run');
|
|
1181
|
+
const planId = url.searchParams.get('plan');
|
|
1182
|
+
const file = url.searchParams.get('path');
|
|
1183
|
+
if ((!runId && !planId) || !file)
|
|
1184
|
+
throw new DoxloopError('A capture request needs a run and a project-relative path.');
|
|
1185
|
+
const workspace = planId ? planCaptureDirectory(root, planId) : runWorkspace(root, runId);
|
|
1186
|
+
const image = assertInside(workspace, resolve(workspace, file));
|
|
1187
|
+
if (extname(image).toLowerCase() !== '.png' || !(await pathExists(image))) {
|
|
1188
|
+
sendJson(response, 404, { error: 'That capture no longer exists.' });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
send(response, 200, 'image/png', await readFile(image), { ...baseHeaders(), 'Cache-Control': 'no-store' });
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (request.method === 'POST' && url.pathname === '/api/proposals/cleanup') {
|
|
1195
|
+
sendJson(response, 200, { removed: await pruneSyncRuns(requireProject(runtime)) });
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const proposalRejectChanges = /^\/api\/proposals\/([a-z0-9-]+)\/reject-changes$/.exec(url.pathname);
|
|
1199
|
+
if (request.method === 'POST' && proposalRejectChanges) {
|
|
1200
|
+
const body = recordBody(await readJsonBody(request));
|
|
1201
|
+
const selections = body.scope === 'folder' ? syncReviewSelectionsFromBody(await readSyncRun(requireProject(runtime), proposalRejectChanges[1]), body) : [{ changeId: stringValue(body.changeId), ...(Array.isArray(body.hunkIds) ? { hunkIds: body.hunkIds.map(stringValue) } : {}) }];
|
|
1202
|
+
sendJson(response, 200, await rejectSyncChanges(requireProject(runtime), proposalRejectChanges[1], selections, optionalString(body.reason) ?? ''));
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const proposalDecision = /^\/api\/proposals\/([a-z0-9-]+)\/(accept|reject)$/.exec(url.pathname);
|
|
1206
|
+
if (request.method === 'POST' && proposalDecision) {
|
|
1207
|
+
const root = requireProject(runtime);
|
|
1208
|
+
if (proposalDecision[2] === 'reject') {
|
|
1209
|
+
sendJson(response, 200, await rejectSyncRun(root, proposalDecision[1]));
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const run = await readSyncRun(root, proposalDecision[1]);
|
|
1213
|
+
const body = await readJsonBody(request);
|
|
1214
|
+
const selections = syncReviewSelectionsFromBody(run, body);
|
|
1215
|
+
const result = await acceptSyncChanges(root, run.id, selections, syncReviewAcceptOptions(body));
|
|
1216
|
+
if (result.status === 'applied' && !run.editRequest)
|
|
1217
|
+
await clearPendingSourceChangesInReceipt(root);
|
|
1218
|
+
sendJson(response, 200, result);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
if (request.method === 'POST' && url.pathname === '/api/author') {
|
|
1222
|
+
const activeAuthoringJob = [...runtime.jobs.values()].find((job) => job.status === 'running' && job.type.startsWith('author:'));
|
|
1223
|
+
if (activeAuthoringJob) {
|
|
1224
|
+
throw new DoxloopError('A documentation run is already in progress. Wait for it to finish or cancel it before starting another.');
|
|
1225
|
+
}
|
|
1226
|
+
const body = recordBody(await readJsonBody(request));
|
|
1227
|
+
const mode = stringValue(body.mode);
|
|
1228
|
+
if (mode !== 'create' && mode !== 'update' && mode !== 'review') {
|
|
1229
|
+
throw new DoxloopError('Authoring mode must be create, update, or review.');
|
|
1230
|
+
}
|
|
1231
|
+
requirePlanFirstAuthoring(mode);
|
|
1232
|
+
const root = requireProject(runtime);
|
|
1233
|
+
let project = await loadProject(root);
|
|
1234
|
+
if (mode === 'create' &&
|
|
1235
|
+
(!project.documentation.primaryAudience ||
|
|
1236
|
+
!project.documentation.priorityOutcomes?.length)) {
|
|
1237
|
+
const documentation = completeDocumentationBriefForCreate(project.documentation);
|
|
1238
|
+
await saveProjectSettings(root, { documentation });
|
|
1239
|
+
project = { ...project, documentation };
|
|
1240
|
+
}
|
|
1241
|
+
const requestedAgent = parseAgent(optionalString(body.agent));
|
|
1242
|
+
const effectiveAgent = requestedAgent ?? project.defaultAgent;
|
|
1243
|
+
const receipt = await readOptionalJson(join(root, '.doxloop', 'last-run.json'));
|
|
1244
|
+
const pendingRemovedSources = receipt && typeof receipt === 'object' && !Array.isArray(receipt)
|
|
1245
|
+
? receipt.pendingRemovedSources
|
|
1246
|
+
: undefined;
|
|
1247
|
+
const removedSources = Array.isArray(pendingRemovedSources)
|
|
1248
|
+
? pendingRemovedSources.filter((entry) => typeof entry === 'string')
|
|
1249
|
+
: [];
|
|
1250
|
+
const removalRequest = removedSources.length > 0
|
|
1251
|
+
? `The following configured sources were removed since the last accepted update: ${removedSources.join(', ')}. Delete documentation pages grounded exclusively in those sources. For pages supported by other active sources, remove the deleted-source evidence and any claims that are no longer supported. Update navigation and .doxloop/evidence-map.json so no stale documentation from the removed sources remains.`
|
|
1252
|
+
: undefined;
|
|
1253
|
+
const requestText = [optionalString(body.request), removalRequest].filter(Boolean).join('\n\n');
|
|
1254
|
+
const args = mode === 'update' ? ['sync', 'now', '--trigger', 'manual'] : [mode];
|
|
1255
|
+
if (mode === 'update')
|
|
1256
|
+
appendOption(args, 'request', requestText || undefined);
|
|
1257
|
+
else if (requestText)
|
|
1258
|
+
args.push(requestText);
|
|
1259
|
+
appendOption(args, 'agent', requestedAgent);
|
|
1260
|
+
appendOption(args, 'model', optionalString(body.model) ?? projectDefaultModel(project, effectiveAgent));
|
|
1261
|
+
if (effectiveAgent === 'codex')
|
|
1262
|
+
appendOption(args, 'reasoning', optionalString(body.reasoning));
|
|
1263
|
+
if (effectiveAgent === 'claude')
|
|
1264
|
+
appendOption(args, 'effort', optionalString(body.effort));
|
|
1265
|
+
const screenshotIntent = normalizeScreenshotIntent(body.screenshots);
|
|
1266
|
+
if (screenshotIntent === 'enabled' && mode !== 'review')
|
|
1267
|
+
args.push('--screenshots');
|
|
1268
|
+
if (screenshotIntent === 'disabled' && mode !== 'review')
|
|
1269
|
+
args.push('--no-screenshots');
|
|
1270
|
+
args.push('--yes', '--cwd', root);
|
|
1271
|
+
sendJson(response, 202, publicJob(startCliJob(runtime, `author:${mode}`, args, root, effectiveAgent)));
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (request.method === 'POST' && url.pathname === '/api/capture') {
|
|
1275
|
+
const body = recordBody(await readJsonBody(request));
|
|
1276
|
+
const urls = stringArray(body.urls);
|
|
1277
|
+
const root = requireProject(runtime);
|
|
1278
|
+
sendJson(response, 202, publicJob(startCliJob(runtime, 'capture', ['capture', ...urls, '--cwd', root], root)));
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
if (request.method === 'POST' && url.pathname === '/api/preview/start') {
|
|
1282
|
+
const body = recordBody(await readJsonBody(request));
|
|
1283
|
+
const open = body.open === true;
|
|
1284
|
+
if (runtime.previewJobId && runtime.previewUrl) {
|
|
1285
|
+
const existing = runtime.jobs.get(runtime.previewJobId);
|
|
1286
|
+
if (existing?.status === 'running') {
|
|
1287
|
+
const root = requireProject(runtime);
|
|
1288
|
+
const project = await loadProject(root);
|
|
1289
|
+
await waitForPreviewServer(runtime.previewUrl, existing, projectPreviewIdentity(project.generator, root));
|
|
1290
|
+
if (open)
|
|
1291
|
+
openBrowser(runtime.previewUrl);
|
|
1292
|
+
sendJson(response, 200, { job: publicJob(existing), url: runtime.previewUrl });
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
const root = requireProject(runtime);
|
|
1297
|
+
const project = await loadProject(root);
|
|
1298
|
+
const previewPort = await availableLocalPort();
|
|
1299
|
+
const previewUrl = `http://127.0.0.1:${previewPort}`;
|
|
1300
|
+
const args = ['preview', '--host', '127.0.0.1', '--port', String(previewPort), '--cwd', root];
|
|
1301
|
+
if (open)
|
|
1302
|
+
args.push('--open');
|
|
1303
|
+
const job = startCliJob(runtime, 'preview', args, root);
|
|
1304
|
+
runtime.previewJobId = job.id;
|
|
1305
|
+
runtime.previewUrl = previewUrl;
|
|
1306
|
+
await waitForPreviewServer(previewUrl, job, projectPreviewIdentity(project.generator, root));
|
|
1307
|
+
sendJson(response, 202, { job: publicJob(job), url: previewUrl });
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
if (request.method === 'POST' && url.pathname === '/api/preview/stop') {
|
|
1311
|
+
const job = runtime.previewJobId ? runtime.jobs.get(runtime.previewJobId) : undefined;
|
|
1312
|
+
if (job?.status === 'running') {
|
|
1313
|
+
job.status = 'cancelled';
|
|
1314
|
+
job.finishedAt = new Date().toISOString();
|
|
1315
|
+
job.child?.kill('SIGTERM');
|
|
1316
|
+
}
|
|
1317
|
+
delete runtime.previewJobId;
|
|
1318
|
+
delete runtime.previewUrl;
|
|
1319
|
+
sendJson(response, 200, { stopped: true });
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (request.method === 'POST' && url.pathname === '/api/agent/install') {
|
|
1323
|
+
const agent = parseAgent(optionalString(recordBody(await readJsonBody(request)).agent));
|
|
1324
|
+
if (!agent)
|
|
1325
|
+
throw new DoxloopError('Choose Codex, Claude Code, or Gemini.');
|
|
1326
|
+
const installed = (await detectAgents()).find((item) => item.name === agent);
|
|
1327
|
+
if (installed) {
|
|
1328
|
+
sendJson(response, 200, { ...installed, alreadyInstalled: true });
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
const existing = [...runtime.jobs.values()].find((job) => job.type === 'agent:install' && job.agent === agent && job.status === 'running');
|
|
1332
|
+
sendJson(response, 202, publicJob(existing ?? startAgentInstallJob(runtime, agent)));
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (request.method === 'POST' && url.pathname === '/api/agent/skills') {
|
|
1336
|
+
const root = requireProject(runtime);
|
|
1337
|
+
const agent = parseAgent(optionalString(recordBody(await readJsonBody(request)).agent));
|
|
1338
|
+
if (!agent)
|
|
1339
|
+
throw new DoxloopError('Choose Codex, Claude Code, or Gemini.');
|
|
1340
|
+
sendJson(response, 200, await installSkill({ root, agent }));
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
if (request.method === 'POST' && url.pathname === '/api/generator') {
|
|
1344
|
+
const body = recordBody(await readJsonBody(request));
|
|
1345
|
+
const action = stringValue(body.action);
|
|
1346
|
+
const generator = stringValue(body.generator);
|
|
1347
|
+
if (action !== 'add' && action !== 'remove')
|
|
1348
|
+
throw new DoxloopError('Generator action must be add or remove.');
|
|
1349
|
+
const root = requireProject(runtime);
|
|
1350
|
+
sendJson(response, 202, publicJob(startCliJob(runtime, 'generator', ['generator', action, generator, '--cwd', root], root)));
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
if (request.method === 'POST' && url.pathname === '/api/auth/login') {
|
|
1354
|
+
const body = recordBody(await readJsonBody(request));
|
|
1355
|
+
const args = ['login'];
|
|
1356
|
+
appendOption(args, 'api-url', optionalString(body.apiUrl));
|
|
1357
|
+
const existing = [...runtime.jobs.values()].find((job) => job.type === 'login' && job.status === 'running');
|
|
1358
|
+
sendJson(response, 202, publicJob(existing ?? startCliJob(runtime, 'login', args, runtime.root ?? runtime.cwd)));
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
if (request.method === 'POST' && url.pathname === '/api/auth/logout') {
|
|
1362
|
+
await logout();
|
|
1363
|
+
sendJson(response, 200, { signedOut: true });
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (request.method === 'POST' && url.pathname === '/api/deploy') {
|
|
1367
|
+
const body = recordBody(await readJsonBody(request));
|
|
1368
|
+
const root = requireProject(runtime);
|
|
1369
|
+
const args = ['deploy', '--yes', '--cwd', root];
|
|
1370
|
+
if (body.dryRun === true)
|
|
1371
|
+
args.push('--dry-run');
|
|
1372
|
+
if (body.public === true)
|
|
1373
|
+
args.push('--public');
|
|
1374
|
+
appendOption(args, 'name', optionalString(body.name));
|
|
1375
|
+
appendOption(args, 'slug', optionalString(body.slug));
|
|
1376
|
+
appendOption(args, 'api-url', optionalString(body.apiUrl));
|
|
1377
|
+
appendOption(args, 'target', optionalString(body.target));
|
|
1378
|
+
appendOption(args, 'site-id', optionalString(body.siteId));
|
|
1379
|
+
appendOption(args, 'project-id', optionalString(body.projectId));
|
|
1380
|
+
appendOption(args, 'team-id', optionalString(body.teamId));
|
|
1381
|
+
appendOption(args, 'branch', optionalString(body.branch));
|
|
1382
|
+
appendOption(args, 'base-path', optionalString(body.basePath));
|
|
1383
|
+
sendJson(response, 202, publicJob(startCliJob(runtime, body.dryRun === true ? 'deploy:dry-run' : 'deploy', args, root)));
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (request.method === 'POST' && url.pathname === '/api/export') {
|
|
1387
|
+
const root = requireProject(runtime);
|
|
1388
|
+
await ensureGitignoreEntries(root, ['.doxloop/exports/']);
|
|
1389
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1390
|
+
const output = join(root, '.doxloop', 'exports', `site-${stamp}`);
|
|
1391
|
+
const args = ['export', '--cwd', root, '--out', output, '--zip'];
|
|
1392
|
+
const body = recordBody(await readJsonBody(request));
|
|
1393
|
+
appendOption(args, 'base-path', optionalString(body.basePath));
|
|
1394
|
+
appendOption(args, 'site-url', optionalString(body.siteUrl));
|
|
1395
|
+
sendJson(response, 202, publicJob(startCliJob(runtime, 'export', args, root)));
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
sendJson(response, 404, { error: 'Not found' });
|
|
1399
|
+
}
|
|
1400
|
+
async function buildUiState(runtime) {
|
|
1401
|
+
const root = runtime.root;
|
|
1402
|
+
if (!root) {
|
|
1403
|
+
const agents = await agentState(undefined, undefined);
|
|
1404
|
+
return {
|
|
1405
|
+
projectFound: false,
|
|
1406
|
+
cwd: runtime.cwd,
|
|
1407
|
+
agents,
|
|
1408
|
+
generators: await installedGeneratorEntries(runtime.cwd),
|
|
1409
|
+
jobs: [...runtime.jobs.values()].map(publicJob).reverse(),
|
|
1410
|
+
recentProjects: await listRecentProjects(),
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
const project = await loadProject(root);
|
|
1414
|
+
const [validation, doctor, runs, syncStatus, drift, agents, account, receipt, recentProjects, deployments] = await Promise.all([
|
|
1415
|
+
safe(() => validateProject(root)),
|
|
1416
|
+
safe(() => runDoctor({ cwd: root })),
|
|
1417
|
+
safe(() => listSyncRuns(root)),
|
|
1418
|
+
safe(() => formatSyncStatus(root, project)),
|
|
1419
|
+
safe(() => computeConfiguredDrift(root, project)),
|
|
1420
|
+
agentState(root, project.defaultAgent),
|
|
1421
|
+
accountState(project),
|
|
1422
|
+
readOptionalJson(join(root, '.doxloop', 'last-run.json')),
|
|
1423
|
+
listRecentProjects(),
|
|
1424
|
+
listDeployments(root, 20),
|
|
1425
|
+
]);
|
|
1426
|
+
// Onboarding placeholders remain blocking in CLI validation and publication.
|
|
1427
|
+
// Before any accepted proposal, present them as setup guidance in the UI.
|
|
1428
|
+
if (!('error' in validation) && Array.isArray(runs) && !runs.some((run) => run.status === 'applied' || run.status === 'partially-applied' || run.changes.some((change) => change.hunks.some((hunk) => hunk.acceptedAt)))) {
|
|
1429
|
+
validation.issues = validation.issues.map((issue) => issue.code === 'starter-content' ? { ...issue, severity: 'warning', message: 'Starter page: approve your first documentation proposal to replace this placeholder.' } : issue);
|
|
1430
|
+
validation.errors = validation.issues.filter((issue) => issue.severity === 'error').length;
|
|
1431
|
+
validation.warnings = validation.issues.filter((issue) => issue.severity === 'warning').length;
|
|
1432
|
+
}
|
|
1433
|
+
return {
|
|
1434
|
+
projectFound: true,
|
|
1435
|
+
cwd: runtime.cwd,
|
|
1436
|
+
root,
|
|
1437
|
+
project,
|
|
1438
|
+
mintlifyImport: await readOptionalJson(join(root, '.doxloop', 'mintlify-import.json')),
|
|
1439
|
+
effectiveDeployment: effectiveDeployment(project),
|
|
1440
|
+
latestDeployment: deployments.find((entry) => entry.status === 'succeeded' && entry.target === effectiveDeployment(project).target && (!effectiveDeployment(project).slug || entry.slug === effectiveDeployment(project).slug)),
|
|
1441
|
+
validation,
|
|
1442
|
+
doctor,
|
|
1443
|
+
runs: Array.isArray(runs) ? runs.map(uiProposal) : runs,
|
|
1444
|
+
syncStatus,
|
|
1445
|
+
drift,
|
|
1446
|
+
agents,
|
|
1447
|
+
account,
|
|
1448
|
+
receipt,
|
|
1449
|
+
documentationPlan: await latestDocumentationPlan(root),
|
|
1450
|
+
generators: (await installedGeneratorEntries(root)).filter((generator) => generator.id === project.generator),
|
|
1451
|
+
jobs: [...runtime.jobs.values()].map(publicJob).reverse(),
|
|
1452
|
+
recentProjects,
|
|
1453
|
+
preview: {
|
|
1454
|
+
running: runtime.previewJobId
|
|
1455
|
+
? runtime.jobs.get(runtime.previewJobId)?.status === 'running'
|
|
1456
|
+
: false,
|
|
1457
|
+
...(runtime.previewUrl ? { url: runtime.previewUrl } : {}),
|
|
1458
|
+
},
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
async function agentState(root, preferred) {
|
|
1462
|
+
const detected = await detectAgents();
|
|
1463
|
+
return Promise.all(detected.map(async (agent) => ({
|
|
1464
|
+
...agent,
|
|
1465
|
+
preferred: agent.name === preferred,
|
|
1466
|
+
authentication: await agentAuthenticationStatus(agent),
|
|
1467
|
+
skills: root ? await skillStatus(root, agent.name) : [],
|
|
1468
|
+
})));
|
|
1469
|
+
}
|
|
1470
|
+
async function accountState(project) {
|
|
1471
|
+
try {
|
|
1472
|
+
const config = await loadUserConfig();
|
|
1473
|
+
const destination = (project.deployment?.target ?? 'doxbrix') === 'doxbrix'
|
|
1474
|
+
? project.deployment?.apiUrl ?? config.apiUrl
|
|
1475
|
+
: config.apiUrl;
|
|
1476
|
+
const user = await authenticatedRequest('/api/v1/me', { method: 'GET' }, destination);
|
|
1477
|
+
return { signedIn: true, apiUrl: destination, user };
|
|
1478
|
+
}
|
|
1479
|
+
catch (error) {
|
|
1480
|
+
const config = await safe(() => loadUserConfig());
|
|
1481
|
+
const destination = isFailure(config) ? 'https://app.doxbrix.com' : (project.deployment?.target ?? 'doxbrix') === 'doxbrix' ? project.deployment?.apiUrl ?? config.apiUrl : config.apiUrl;
|
|
1482
|
+
const elsewhere = isFailure(config) ? [] : signedInServers(config).filter((url) => url !== destination);
|
|
1483
|
+
return {
|
|
1484
|
+
signedIn: false,
|
|
1485
|
+
apiUrl: destination,
|
|
1486
|
+
...(elsewhere.length ? { signedInElsewhere: elsewhere } : {}),
|
|
1487
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* The rendered reader site lives on a host Doxbrix provisions (`hostedSlug`), so
|
|
1493
|
+
* it cannot be derived locally from the project slug. Ask Doxbrix for it, and
|
|
1494
|
+
* report nothing rather than failing when signed out or not yet deployed.
|
|
1495
|
+
*/
|
|
1496
|
+
async function deployedSiteUrl(root) {
|
|
1497
|
+
try {
|
|
1498
|
+
const project = await loadProject(root);
|
|
1499
|
+
if ((project.deployment?.target ?? 'doxbrix') !== 'doxbrix')
|
|
1500
|
+
return null;
|
|
1501
|
+
const deployment = effectiveDeployment(project);
|
|
1502
|
+
const found = await authenticatedRequestOptional(`/api/v1/projects/${encodeURIComponent(deployment.slug)}`, { method: 'GET' }, deployment.apiUrl);
|
|
1503
|
+
return found?.project?.hostedUrl ?? null;
|
|
1504
|
+
}
|
|
1505
|
+
catch {
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
async function createProjectFromUi(runtime, raw) {
|
|
1510
|
+
const body = recordBody(raw);
|
|
1511
|
+
// Creating a project opens it, which is a switch for an already-open workspace.
|
|
1512
|
+
assertProjectSwitchAllowed(runtime.jobs.values());
|
|
1513
|
+
const title = optionalString(body.title);
|
|
1514
|
+
const generator = parseGenerator(optionalString(body.generator)) ?? 'doxbrix';
|
|
1515
|
+
const application = body.application !== undefined ? applicationFromBody(body.application) : undefined;
|
|
1516
|
+
const root = resolveUiDocumentationDirectory(await newProjectParent(runtime, body), stringValue(body.directory));
|
|
1517
|
+
await assertUiProjectDirectoryAvailable(root);
|
|
1518
|
+
await assertNewProjectDirectory(root);
|
|
1519
|
+
const sourceKind = optionalString(body.sourceKind);
|
|
1520
|
+
const sourceLocation = optionalString(body.sourceLocation) ?? 'local';
|
|
1521
|
+
const sourcePath = optionalString(body.sourcePath);
|
|
1522
|
+
const sources = [];
|
|
1523
|
+
const sourceBodies = Array.isArray(body.sources)
|
|
1524
|
+
? body.sources.map(recordBody)
|
|
1525
|
+
: [{ ...body, sourceKind, sourceLocation, sourcePath }];
|
|
1526
|
+
const usedNames = new Set();
|
|
1527
|
+
for (const sourceBody of sourceBodies) {
|
|
1528
|
+
const itemKind = optionalString(sourceBody.sourceKind);
|
|
1529
|
+
const itemLocation = optionalString(sourceBody.sourceLocation) ?? 'local';
|
|
1530
|
+
const itemPath = optionalString(sourceBody.sourcePath);
|
|
1531
|
+
const specificationContent = optionalString(sourceBody.specContent);
|
|
1532
|
+
const baseName = sourceIdentifier(optionalString(sourceBody.name) ?? optionalString(sourceBody.sourceName) ?? (itemKind === 'openapi' ? 'api' : itemKind === 'docs-site' ? 'docs' : 'product'));
|
|
1533
|
+
let name = baseName;
|
|
1534
|
+
let suffix = 2;
|
|
1535
|
+
while (usedNames.has(name))
|
|
1536
|
+
name = `${baseName}-${suffix++}`;
|
|
1537
|
+
usedNames.add(name);
|
|
1538
|
+
if (itemKind === 'directory' && itemLocation === 'git') {
|
|
1539
|
+
const remote = remoteSourceFromSetupBody(sourceBody);
|
|
1540
|
+
const prepared = await materializeRemoteSource(root, { name, remote });
|
|
1541
|
+
sources.push({ name, path: portableSourcePath(root, prepared.path), remote });
|
|
1542
|
+
}
|
|
1543
|
+
else if (itemKind === 'directory' && itemPath) {
|
|
1544
|
+
sources.push({ name, path: portableRelative(root, resolve(runtime.cwd, itemPath)) });
|
|
1545
|
+
}
|
|
1546
|
+
else if (itemKind === 'openapi' && specificationContent) {
|
|
1547
|
+
parseOpenApi(specificationContent, `OpenAPI source "${name}"`);
|
|
1548
|
+
const extension = specificationContent.trimStart().startsWith('{') ? 'json' : 'yaml';
|
|
1549
|
+
const inlineDirectory = resolve(root, '..', '.doxloop-sources', sourceIdentifier(root.split(sep).pop() ?? 'project'), 'openapi');
|
|
1550
|
+
const inlinePath = join(inlineDirectory, `${name}.${extension}`);
|
|
1551
|
+
await mkdir(inlineDirectory, { recursive: true });
|
|
1552
|
+
await writeFile(inlinePath, `${specificationContent.trim()}\n`, 'utf8');
|
|
1553
|
+
sources.push({ name, path: portableRelative(root, inlinePath), kind: 'openapi' });
|
|
1554
|
+
}
|
|
1555
|
+
else if (itemKind === 'openapi' && itemPath) {
|
|
1556
|
+
const parsed = parseSpec(`${name}=${itemPath}`);
|
|
1557
|
+
sources.push(isSpecUrl(parsed.path) ? parsed : { ...parsed, path: portableRelative(root, resolve(runtime.cwd, parsed.path)) });
|
|
1558
|
+
}
|
|
1559
|
+
else if (itemKind === 'docs-site') {
|
|
1560
|
+
const snapshot = await docsSiteSnapshotFromBody(runtime, sourceBody);
|
|
1561
|
+
sources.push(docsSiteBinding(root, name, await materializeDocsSiteSnapshot(root, name, snapshot)));
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
await validateProjectSourceBoundaries(root, sources);
|
|
1565
|
+
if (generator !== 'doxbrix') {
|
|
1566
|
+
await mkdir(root, { recursive: true });
|
|
1567
|
+
await addGenerator(root, generator);
|
|
1568
|
+
}
|
|
1569
|
+
await scaffoldProject({ directory: root, ...(title ? { title } : {}), sources, generator });
|
|
1570
|
+
const agent = parseAgent(optionalString(body.agent));
|
|
1571
|
+
await installSkill({ root, ...(agent ? { agent } : {}) });
|
|
1572
|
+
const documentation = completeDocumentationBriefForCreate(body.documentation === undefined
|
|
1573
|
+
? defaultDocumentationBrief()
|
|
1574
|
+
: documentationFromBody(body.documentation, defaultDocumentationBrief()));
|
|
1575
|
+
await saveProjectSettings(root, {
|
|
1576
|
+
...(agent ? { defaultAgent: agent } : {}),
|
|
1577
|
+
// The model chosen in the wizard is the project's default from then on,
|
|
1578
|
+
// so later updates and page edits use the same model as the first run.
|
|
1579
|
+
...(agent && optionalString(body.model) ? { defaultModel: optionalString(body.model) } : {}),
|
|
1580
|
+
documentation,
|
|
1581
|
+
...(application ? { application } : {}),
|
|
1582
|
+
});
|
|
1583
|
+
// Sign-in material gathered by the wizard is stored against the new root,
|
|
1584
|
+
// outside the project, now that the root exists.
|
|
1585
|
+
if (application) {
|
|
1586
|
+
const credentials = body.applicationCredentials === undefined || body.applicationCredentials === null ? undefined : recordBody(body.applicationCredentials);
|
|
1587
|
+
const username = credentials ? optionalString(credentials.username) : undefined;
|
|
1588
|
+
const password = credentials && typeof credentials.password === 'string' ? credentials.password : '';
|
|
1589
|
+
if (username && password)
|
|
1590
|
+
await saveCaptureCredentials(root, { username, password });
|
|
1591
|
+
if (runtime.pendingCaptureSession)
|
|
1592
|
+
await saveCaptureSession(root, runtime.pendingCaptureSession.origin, runtime.pendingCaptureSession.state);
|
|
1593
|
+
// Reusing another project's sign-in copies it server side: the secret
|
|
1594
|
+
// never travels through the browser. Only a recent project whose
|
|
1595
|
+
// application URL matches can be the source.
|
|
1596
|
+
const reuseFrom = optionalString(body.applicationSignInFrom);
|
|
1597
|
+
if (reuseFrom && !(username && password) && !runtime.pendingCaptureSession) {
|
|
1598
|
+
const match = (await reusableSignIns(application.baseUrl)).find((candidate) => candidate.path === resolve(reuseFrom));
|
|
1599
|
+
if (!match)
|
|
1600
|
+
throw new DoxloopError('That saved sign-in is not available for this application URL.');
|
|
1601
|
+
const [savedCredentials, savedSession] = await Promise.all([loadCaptureCredentials(match.path), loadCaptureSession(match.path)]);
|
|
1602
|
+
if (savedCredentials)
|
|
1603
|
+
await saveCaptureCredentials(root, { username: savedCredentials.username, password: savedCredentials.password });
|
|
1604
|
+
if (savedSession)
|
|
1605
|
+
await saveCaptureSession(root, savedSession.origin, savedSession.state);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
runtime.pendingCaptureSession = undefined;
|
|
1609
|
+
await switchProject(runtime, root);
|
|
1610
|
+
}
|
|
1611
|
+
/** Recent projects that already hold a sign-in for the same application. */
|
|
1612
|
+
async function reusableSignIns(baseUrl) {
|
|
1613
|
+
const target = comparableApplicationUrl(baseUrl);
|
|
1614
|
+
if (!target)
|
|
1615
|
+
return [];
|
|
1616
|
+
const matches = [];
|
|
1617
|
+
for (const entry of await listRecentProjects()) {
|
|
1618
|
+
if (entry.missing)
|
|
1619
|
+
continue;
|
|
1620
|
+
try {
|
|
1621
|
+
const project = await loadProject(entry.path);
|
|
1622
|
+
if (comparableApplicationUrl(project.application?.baseUrl ?? '') !== target)
|
|
1623
|
+
continue;
|
|
1624
|
+
const status = await captureAuthStatus(entry.path);
|
|
1625
|
+
if (!status.credentials && !status.session)
|
|
1626
|
+
continue;
|
|
1627
|
+
const loginPath = project.application?.authentication?.loginPath;
|
|
1628
|
+
matches.push({ path: resolve(entry.path), title: project.title || entry.title, ...(status.credentials ? { username: status.credentials.username } : {}), session: Boolean(status.session), ...(loginPath ? { loginPath } : {}) });
|
|
1629
|
+
}
|
|
1630
|
+
catch { /* An unreadable project is simply not offered. */ }
|
|
1631
|
+
}
|
|
1632
|
+
return matches;
|
|
1633
|
+
}
|
|
1634
|
+
function comparableApplicationUrl(value) {
|
|
1635
|
+
try {
|
|
1636
|
+
const parsed = new URL(value.trim());
|
|
1637
|
+
const hash = parsed.hash.replace(/^#\/?/, '').replace(/\/+$/, '');
|
|
1638
|
+
return `${parsed.origin.toLowerCase()}${parsed.pathname.replace(/\/+$/, '')}${hash ? `#/${hash}` : ''}`;
|
|
1639
|
+
}
|
|
1640
|
+
catch {
|
|
1641
|
+
return '';
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
async function captureAuthState(runtime, root) {
|
|
1645
|
+
return { ...(await captureAuthStatus(root)), signIn: captureSignInState(runtime, 'project') };
|
|
1646
|
+
}
|
|
1647
|
+
function setupCaptureAuthState(runtime) {
|
|
1648
|
+
const pending = runtime.pendingCaptureSession;
|
|
1649
|
+
return {
|
|
1650
|
+
...(pending ? { session: { savedAt: pending.savedAt, origin: pending.origin, cookies: pending.state.cookies.length, origins: pending.state.origins.length } } : {}),
|
|
1651
|
+
signIn: captureSignInState(runtime, 'setup'),
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
function captureSignInState(runtime, scope) {
|
|
1655
|
+
const active = runtime.captureSignIn;
|
|
1656
|
+
if (!active || active.scope !== scope)
|
|
1657
|
+
return { active: false };
|
|
1658
|
+
return { active: true, open: active.session.open(), url: active.session.url, currentUrl: active.session.currentUrl() };
|
|
1659
|
+
}
|
|
1660
|
+
async function beginCaptureSignIn(runtime, application, scope) {
|
|
1661
|
+
// One visible window at a time: a forgotten earlier window is closed first.
|
|
1662
|
+
await cancelCaptureSignIn(runtime);
|
|
1663
|
+
const session = await startCaptureSignIn(application);
|
|
1664
|
+
runtime.captureSignIn = { session, scope, origin: new URL(application.baseUrl).origin };
|
|
1665
|
+
}
|
|
1666
|
+
async function cancelCaptureSignIn(runtime) {
|
|
1667
|
+
const active = runtime.captureSignIn;
|
|
1668
|
+
runtime.captureSignIn = undefined;
|
|
1669
|
+
if (active)
|
|
1670
|
+
await active.session.cancel();
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* New projects are created next to the folder the control center was opened
|
|
1674
|
+
* from unless the wizard names another parent, which lets a second project
|
|
1675
|
+
* start from an open workspace without restarting `doxloop ui`.
|
|
1676
|
+
*/
|
|
1677
|
+
async function newProjectParent(runtime, body) {
|
|
1678
|
+
const requested = optionalString(body.parentDirectory);
|
|
1679
|
+
if (!requested)
|
|
1680
|
+
return runtime.cwd;
|
|
1681
|
+
const parent = resolve(runtime.cwd, requested);
|
|
1682
|
+
let stats;
|
|
1683
|
+
try {
|
|
1684
|
+
stats = await stat(parent);
|
|
1685
|
+
}
|
|
1686
|
+
catch {
|
|
1687
|
+
throw new DoxloopError(`The location does not exist: ${parent}`, 2);
|
|
1688
|
+
}
|
|
1689
|
+
if (!stats.isDirectory())
|
|
1690
|
+
throw new DoxloopError(`The location is not a directory: ${parent}`, 2);
|
|
1691
|
+
return parent;
|
|
1692
|
+
}
|
|
1693
|
+
async function resolveProjectToOpen(runtime, raw) {
|
|
1694
|
+
const candidate = resolve(runtime.cwd, raw);
|
|
1695
|
+
try {
|
|
1696
|
+
if (!(await stat(candidate)).isDirectory())
|
|
1697
|
+
throw new DoxloopError(`The folder is not a directory: ${candidate}`, 2);
|
|
1698
|
+
}
|
|
1699
|
+
catch (error) {
|
|
1700
|
+
if (error instanceof DoxloopError)
|
|
1701
|
+
throw error;
|
|
1702
|
+
throw new DoxloopError(`The folder does not exist: ${candidate}`, 2);
|
|
1703
|
+
}
|
|
1704
|
+
try {
|
|
1705
|
+
return await findProjectRoot(candidate);
|
|
1706
|
+
}
|
|
1707
|
+
catch {
|
|
1708
|
+
throw new DoxloopError(`${candidate} is not a Doxloop project. Import its existing documentation, or start a new project.`, 2);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
function importOverrides(body) {
|
|
1712
|
+
const generator = parseGenerator(optionalString(body.generator));
|
|
1713
|
+
const contentDir = typeof body.contentDir === 'string' ? body.contentDir : undefined;
|
|
1714
|
+
return { ...(generator ? { generator } : {}), ...(contentDir !== undefined ? { contentDir } : {}) };
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Only one project is open at a time. Switching ends the previews, which
|
|
1718
|
+
* serve the project that started them, and refuses while anything else runs
|
|
1719
|
+
* so a job never finishes into a workspace that is no longer shown.
|
|
1720
|
+
*/
|
|
1721
|
+
async function switchProject(runtime, root) {
|
|
1722
|
+
assertProjectSwitchAllowed(runtime.jobs.values());
|
|
1723
|
+
if (runtime.root === root) {
|
|
1724
|
+
await rememberOpenProject(root);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
// A sign-in window belongs to the project it was opened for.
|
|
1728
|
+
await cancelCaptureSignIn(runtime);
|
|
1729
|
+
for (const job of runtime.jobs.values()) {
|
|
1730
|
+
if (job.status !== 'running')
|
|
1731
|
+
continue;
|
|
1732
|
+
job.status = 'cancelled';
|
|
1733
|
+
job.finishedAt = new Date().toISOString();
|
|
1734
|
+
noteJob(runtime, job, 'The preview stopped because another project was opened.');
|
|
1735
|
+
queueUiJobLog(runtime, job.id, '\nThe preview stopped because another project was opened.\n');
|
|
1736
|
+
job.child?.stdout?.removeAllListeners('data');
|
|
1737
|
+
job.child?.stderr?.removeAllListeners('data');
|
|
1738
|
+
stopJobChild(job.child);
|
|
1739
|
+
delete job.child;
|
|
1740
|
+
}
|
|
1741
|
+
if (runtime.root) {
|
|
1742
|
+
await flushUiJobLogs(runtime);
|
|
1743
|
+
await flushUiJobs(runtime);
|
|
1744
|
+
}
|
|
1745
|
+
const { jobs, recovered } = await loadRuntimeJobs(root);
|
|
1746
|
+
runtime.root = root;
|
|
1747
|
+
runtime.jobs = jobs;
|
|
1748
|
+
runtime.previewJobId = undefined;
|
|
1749
|
+
runtime.previewUrl = undefined;
|
|
1750
|
+
runtime.proposalPreviewJobId = undefined;
|
|
1751
|
+
runtime.proposalPreviewRunId = undefined;
|
|
1752
|
+
runtime.proposalPreviewUrl = undefined;
|
|
1753
|
+
if (recovered)
|
|
1754
|
+
await persistUiJobs(runtime);
|
|
1755
|
+
broadcastJobs(runtime);
|
|
1756
|
+
await rememberOpenProject(root);
|
|
1757
|
+
}
|
|
1758
|
+
export function assertProjectSwitchAllowed(jobs) {
|
|
1759
|
+
const active = [...jobs].find((job) => job.status === 'running' && !isPreviewJobType(job.type));
|
|
1760
|
+
if (active) {
|
|
1761
|
+
throw new DoxloopError('A run is still in progress. Wait for it to finish or stop it before opening another project.');
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
function isPreviewJobType(type) {
|
|
1765
|
+
return type === 'preview' || type.startsWith('proposal-preview:');
|
|
1766
|
+
}
|
|
1767
|
+
async function loadRuntimeJobs(root) {
|
|
1768
|
+
const jobs = await loadUiJobs(root);
|
|
1769
|
+
let recovered = false;
|
|
1770
|
+
for (const job of jobs.values()) {
|
|
1771
|
+
if (job.status !== 'running')
|
|
1772
|
+
continue;
|
|
1773
|
+
job.status = 'failed';
|
|
1774
|
+
job.finishedAt = new Date().toISOString();
|
|
1775
|
+
job.recovered = true;
|
|
1776
|
+
appendJobLines(job, ['The previous Doxloop UI server stopped before this run completed. Partial logs were preserved and this stage can be retried safely.']);
|
|
1777
|
+
recovered = true;
|
|
1778
|
+
await reconcileInterruptedPlan(root, job, 'The previous Doxloop UI server stopped before this run completed. Retry the stage to run it again.');
|
|
1779
|
+
}
|
|
1780
|
+
// A plan job that ended while no UI server was watching it (for example a
|
|
1781
|
+
// process killed from a terminal) may also have left its plan mid-stage.
|
|
1782
|
+
for (const job of jobs.values()) {
|
|
1783
|
+
if (job.status === 'failed')
|
|
1784
|
+
await reconcileInterruptedPlan(root, job, 'The run stopped before it reported a result. Retry the stage to run it again.');
|
|
1785
|
+
// A server shutdown cancels its jobs; their runs must not stay "generating".
|
|
1786
|
+
if (job.status === 'cancelled')
|
|
1787
|
+
await reconcileInterruptedRuns(root, job, 'The run was stopped before it finished. Resume it from Review to continue in its preserved workspace.');
|
|
1788
|
+
}
|
|
1789
|
+
return { jobs, recovered };
|
|
1790
|
+
}
|
|
1791
|
+
async function rememberOpenProject(root) {
|
|
1792
|
+
try {
|
|
1793
|
+
const project = await loadProject(root);
|
|
1794
|
+
await rememberProject({ path: root, title: project.title, generator: project.generator });
|
|
1795
|
+
}
|
|
1796
|
+
catch {
|
|
1797
|
+
// The recent list is a convenience. A project that cannot be read still
|
|
1798
|
+
// opens so its problem is shown in the workspace.
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
async function validateSetupPaths(runtime, raw) {
|
|
1802
|
+
const body = recordBody(raw);
|
|
1803
|
+
const directory = optionalString(body.directory);
|
|
1804
|
+
let root;
|
|
1805
|
+
let directoryError;
|
|
1806
|
+
if (!directory) {
|
|
1807
|
+
directoryError = 'Enter a documentation directory.';
|
|
1808
|
+
}
|
|
1809
|
+
else {
|
|
1810
|
+
try {
|
|
1811
|
+
root = resolveUiDocumentationDirectory(await newProjectParent(runtime, body), directory);
|
|
1812
|
+
await assertUiProjectDirectoryAvailable(root);
|
|
1813
|
+
}
|
|
1814
|
+
catch (error) {
|
|
1815
|
+
directoryError = error instanceof Error ? error.message : String(error);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
const sourceKind = optionalString(body.sourceKind);
|
|
1819
|
+
const sourceLocation = optionalString(body.sourceLocation) ?? 'local';
|
|
1820
|
+
const sourcePath = optionalString(body.sourcePath);
|
|
1821
|
+
const sourceSpecContent = optionalString(body.specContent);
|
|
1822
|
+
let resolvedSourcePath;
|
|
1823
|
+
let sourcePathError;
|
|
1824
|
+
let openapiSummary;
|
|
1825
|
+
if (sourceKind && sourceKind !== 'none') {
|
|
1826
|
+
if (sourceKind === 'directory' && sourceLocation === 'git') {
|
|
1827
|
+
try {
|
|
1828
|
+
await remoteHead(remoteSourceFromSetupBody(body));
|
|
1829
|
+
}
|
|
1830
|
+
catch (error) {
|
|
1831
|
+
sourcePathError = error instanceof Error ? error.message : String(error);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
else if (sourceKind === 'openapi' && sourceSpecContent) {
|
|
1835
|
+
try {
|
|
1836
|
+
openapiSummary = parseOpenApi(sourceSpecContent, 'Uploaded OpenAPI specification').summary;
|
|
1837
|
+
}
|
|
1838
|
+
catch (error) {
|
|
1839
|
+
sourcePathError = error instanceof Error ? error.message : String(error);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
else if (sourceKind === 'docs-site') {
|
|
1843
|
+
try {
|
|
1844
|
+
if (!sourcePath)
|
|
1845
|
+
throw new DoxloopError('Enter the address of the existing documentation site.');
|
|
1846
|
+
resolvedSourcePath = docsSiteScope(sourcePath).entry;
|
|
1847
|
+
const inspectionId = optionalString(body.inspectionId);
|
|
1848
|
+
if (!inspectionId)
|
|
1849
|
+
throw new DoxloopError('Crawl the documentation site before adding it as a source.');
|
|
1850
|
+
// An expired inspection is re-crawled when the project is created.
|
|
1851
|
+
const inspection = runtime.docsSiteInspections.get(inspectionId);
|
|
1852
|
+
if (inspection?.status === 'running')
|
|
1853
|
+
throw new DoxloopError('The documentation site is still being crawled. Wait for the crawl to finish.');
|
|
1854
|
+
if (inspection?.status === 'failed')
|
|
1855
|
+
throw new DoxloopError(`The documentation site crawl failed: ${inspection.error ?? 'unknown error'}`);
|
|
1856
|
+
}
|
|
1857
|
+
catch (error) {
|
|
1858
|
+
sourcePathError = error instanceof Error ? error.message : String(error);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
else if (!sourcePath) {
|
|
1862
|
+
sourcePathError = sourceKind === 'directory' ? 'Choose a product source directory.' : 'Enter an OpenAPI file path or URL.';
|
|
1863
|
+
}
|
|
1864
|
+
else if (root) {
|
|
1865
|
+
try {
|
|
1866
|
+
if (sourceKind === 'directory') {
|
|
1867
|
+
resolvedSourcePath = resolve(runtime.cwd, sourcePath);
|
|
1868
|
+
await validateProjectSourceBoundaries(root, [{ name: 'product', path: portableRelative(root, resolvedSourcePath) }]);
|
|
1869
|
+
}
|
|
1870
|
+
else if (sourceKind === 'openapi') {
|
|
1871
|
+
const parsed = parseSpec(`api=${sourcePath}`);
|
|
1872
|
+
resolvedSourcePath = isSpecUrl(parsed.path) ? parsed.path : resolve(runtime.cwd, parsed.path);
|
|
1873
|
+
const binding = isSpecUrl(parsed.path) ? parsed : { ...parsed, path: portableRelative(root, resolvedSourcePath) };
|
|
1874
|
+
await validateProjectSourceBoundaries(root, [binding]);
|
|
1875
|
+
openapiSummary = (await loadOpenApiSource(root, binding)).summary;
|
|
1876
|
+
}
|
|
1877
|
+
else {
|
|
1878
|
+
sourcePathError = 'Choose a supported source type.';
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
catch (error) {
|
|
1882
|
+
sourcePathError = error instanceof Error ? error.message : String(error);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
return {
|
|
1887
|
+
...(root ? { directoryPath: root } : {}),
|
|
1888
|
+
...(directoryError ? { directoryError } : {}),
|
|
1889
|
+
...(resolvedSourcePath ? { sourcePath: resolvedSourcePath } : {}),
|
|
1890
|
+
...(sourcePathError ? { sourcePathError } : {}),
|
|
1891
|
+
...(openapiSummary ? { openapiSummary } : {}),
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
function remoteSourceFromSetupBody(body) {
|
|
1895
|
+
const repository = parseGitRepository(stringValue(body.repository));
|
|
1896
|
+
const branch = stringValue(body.branch);
|
|
1897
|
+
const subdirectory = optionalString(body.subdirectory);
|
|
1898
|
+
configureGitAccess(body, repository);
|
|
1899
|
+
if (subdirectory && (subdirectory.startsWith('/') || subdirectory.split(/[\\/]/).includes('..'))) {
|
|
1900
|
+
throw new DoxloopError('Repository subdirectory must be a safe relative directory.');
|
|
1901
|
+
}
|
|
1902
|
+
return {
|
|
1903
|
+
provider: 'git',
|
|
1904
|
+
repository,
|
|
1905
|
+
branch,
|
|
1906
|
+
...(subdirectory ? { subdirectory } : {}),
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
function configureGitAccess(body, repository) {
|
|
1910
|
+
const method = optionalString(body.authMethod) ?? 'automatic';
|
|
1911
|
+
if (method !== 'automatic' && method !== 'credentials')
|
|
1912
|
+
throw new DoxloopError('Choose a supported repository access option.');
|
|
1913
|
+
const secret = method === 'credentials' ? optionalString(body.gitSecret) : undefined;
|
|
1914
|
+
if (method === 'credentials' && !secret)
|
|
1915
|
+
throw new DoxloopError('Enter the password or access key for this private repository.');
|
|
1916
|
+
rememberRemoteCredential(repository, optionalString(body.gitUsername), secret);
|
|
1917
|
+
}
|
|
1918
|
+
async function assertUiProjectDirectoryAvailable(path) {
|
|
1919
|
+
try {
|
|
1920
|
+
await stat(path);
|
|
1921
|
+
}
|
|
1922
|
+
catch (error) {
|
|
1923
|
+
if (isNodeError(error) && error.code === 'ENOENT')
|
|
1924
|
+
return;
|
|
1925
|
+
throw error;
|
|
1926
|
+
}
|
|
1927
|
+
throw new DoxloopError(`The documentation path already exists: ${path}\nChoose a different directory name.`);
|
|
1928
|
+
}
|
|
1929
|
+
function resolveUiDocumentationDirectory(cwd, name) {
|
|
1930
|
+
if (name === '.' || name === '..' || /[\\/]/.test(name)) {
|
|
1931
|
+
throw new DoxloopError('Enter a directory name, not a filesystem path.');
|
|
1932
|
+
}
|
|
1933
|
+
return resolve(cwd, name);
|
|
1934
|
+
}
|
|
1935
|
+
async function updateProjectFromUi(root, raw) {
|
|
1936
|
+
const body = recordBody(raw);
|
|
1937
|
+
const project = await loadProject(root);
|
|
1938
|
+
const update = {};
|
|
1939
|
+
const title = optionalString(body.title);
|
|
1940
|
+
if (title)
|
|
1941
|
+
update.title = title;
|
|
1942
|
+
if ('defaultAgent' in body) {
|
|
1943
|
+
const rawAgent = optionalString(body.defaultAgent);
|
|
1944
|
+
const agent = parseAgent(rawAgent);
|
|
1945
|
+
if (rawAgent && !agent)
|
|
1946
|
+
throw new DoxloopError('Unsupported default agent.');
|
|
1947
|
+
update.defaultAgent = agent;
|
|
1948
|
+
// A model belongs to the agent it was chosen for, so clearing or
|
|
1949
|
+
// switching the agent drops it unless the request names a new one.
|
|
1950
|
+
if (!('defaultModel' in body))
|
|
1951
|
+
update.defaultModel = agent === project.defaultAgent ? project.defaultModel : undefined;
|
|
1952
|
+
}
|
|
1953
|
+
if ('defaultModel' in body) {
|
|
1954
|
+
const model = optionalString(body.defaultModel)?.trim();
|
|
1955
|
+
const agent = 'defaultAgent' in body ? update.defaultAgent : project.defaultAgent;
|
|
1956
|
+
update.defaultModel = agent && model ? model : undefined;
|
|
1957
|
+
}
|
|
1958
|
+
if (body.documentation !== undefined) {
|
|
1959
|
+
update.documentation = documentationFromBody(body.documentation, project.documentation);
|
|
1960
|
+
}
|
|
1961
|
+
if (body.designReferences !== undefined) {
|
|
1962
|
+
update.designReferences = stringArray(body.designReferences).map(parseDesignReference);
|
|
1963
|
+
}
|
|
1964
|
+
if (body.application !== undefined) {
|
|
1965
|
+
update.application = body.application === null ? undefined : applicationFromBody(body.application, project);
|
|
1966
|
+
}
|
|
1967
|
+
if (body.deployment !== undefined) {
|
|
1968
|
+
update.deployment = deploymentFromBody(body.deployment);
|
|
1969
|
+
}
|
|
1970
|
+
await saveProjectSettings(root, update);
|
|
1971
|
+
}
|
|
1972
|
+
async function addSourceFromUi(runtime, root, raw) {
|
|
1973
|
+
const body = recordBody(raw);
|
|
1974
|
+
const project = await loadProject(root);
|
|
1975
|
+
const name = stringValue(body.name);
|
|
1976
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name))
|
|
1977
|
+
throw new DoxloopError('Source name may use letters, numbers, underscores, and hyphens.');
|
|
1978
|
+
if (project.sources.some((source) => source.name === name))
|
|
1979
|
+
throw new DoxloopError(`Source "${name}" already exists.`);
|
|
1980
|
+
const kind = stringValue(body.kind);
|
|
1981
|
+
const location = optionalString(body.path);
|
|
1982
|
+
const specificationContent = optionalString(body.specContent);
|
|
1983
|
+
const scope = sourceScopeFromBody(body);
|
|
1984
|
+
let source;
|
|
1985
|
+
if (kind === 'openapi' && specificationContent) {
|
|
1986
|
+
parseOpenApi(specificationContent, 'Uploaded OpenAPI specification');
|
|
1987
|
+
const extension = specificationContent.trimStart().startsWith('{') ? 'json' : 'yaml';
|
|
1988
|
+
const inlineDirectory = resolve(root, '..', '.doxloop-sources', sourceIdentifier(root.split(sep).pop() ?? 'project'), 'openapi');
|
|
1989
|
+
const inlinePath = join(inlineDirectory, `${name}.${extension}`);
|
|
1990
|
+
await mkdir(inlineDirectory, { recursive: true });
|
|
1991
|
+
await writeFile(inlinePath, `${specificationContent.trim()}\n`, 'utf8');
|
|
1992
|
+
source = { name, path: portableRelative(root, inlinePath), kind: 'openapi', ...(scope ? { scope } : {}) };
|
|
1993
|
+
}
|
|
1994
|
+
else if (kind === 'openapi') {
|
|
1995
|
+
const parsed = parseSpec(`${name}=${location ?? ''}`);
|
|
1996
|
+
source = { ...(isSpecUrl(parsed.path) ? parsed : { ...parsed, path: portableRelative(root, resolve(root, parsed.path)) }), ...(scope ? { scope } : {}) };
|
|
1997
|
+
}
|
|
1998
|
+
else if (kind === 'directory') {
|
|
1999
|
+
if (!location)
|
|
2000
|
+
throw new DoxloopError('Choose a product source directory.');
|
|
2001
|
+
source = { name, path: portableRelative(root, resolve(root, location)), ...(scope ? { scope } : {}) };
|
|
2002
|
+
}
|
|
2003
|
+
else if (kind === 'git') {
|
|
2004
|
+
const remote = remoteSourceFromSetupBody(body);
|
|
2005
|
+
const prepared = await materializeRemoteSource(root, { name, remote });
|
|
2006
|
+
source = { name, path: portableSourcePath(root, prepared.path), remote, ...(scope ? { scope } : {}) };
|
|
2007
|
+
}
|
|
2008
|
+
else if (kind === 'docs-site') {
|
|
2009
|
+
const snapshot = await docsSiteSnapshotFromBody(runtime, body);
|
|
2010
|
+
source = docsSiteBinding(root, name, await materializeDocsSiteSnapshot(root, name, snapshot), scope);
|
|
2011
|
+
}
|
|
2012
|
+
else {
|
|
2013
|
+
throw new DoxloopError('Source type must be directory, git, openapi, or docs-site.');
|
|
2014
|
+
}
|
|
2015
|
+
const sources = [...project.sources, source];
|
|
2016
|
+
await validateProjectSourceBoundaries(root, sources);
|
|
2017
|
+
await saveProjectSettings(root, { sources });
|
|
2018
|
+
await setPendingSourceInReceipt(root, name, true);
|
|
2019
|
+
await setPendingRemovedSourceInReceipt(root, name, false);
|
|
2020
|
+
}
|
|
2021
|
+
async function updateSourceFromUi(root, name, raw) {
|
|
2022
|
+
const body = recordBody(raw);
|
|
2023
|
+
const project = await loadProject(root);
|
|
2024
|
+
const index = project.sources.findIndex((source) => source.name === name);
|
|
2025
|
+
if (index < 0)
|
|
2026
|
+
throw new DoxloopError(`Source "${name}" does not exist.`);
|
|
2027
|
+
let source = project.sources[index];
|
|
2028
|
+
const sources = [...project.sources];
|
|
2029
|
+
if (body.path !== undefined || body.kind !== undefined) {
|
|
2030
|
+
const kind = optionalString(body.kind) ?? (source.kind ?? 'directory');
|
|
2031
|
+
const location = optionalString(body.path) ?? source.path;
|
|
2032
|
+
if (kind === 'openapi') {
|
|
2033
|
+
const parsed = parseSpec(`${source.name}=${location}`);
|
|
2034
|
+
source = isSpecUrl(parsed.path) ? parsed : { ...parsed, path: portableRelative(root, resolve(root, parsed.path)) };
|
|
2035
|
+
}
|
|
2036
|
+
else if (kind === 'directory') {
|
|
2037
|
+
source = {
|
|
2038
|
+
name: source.name,
|
|
2039
|
+
path: portableRelative(root, resolve(root, location)),
|
|
2040
|
+
...(source.remote ? { remote: source.remote } : {}),
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
else if (kind === 'docs-site') {
|
|
2044
|
+
throw new DoxloopError('A documentation site source is re-crawled from Sources; to change its address, remove it and add the new site.');
|
|
2045
|
+
}
|
|
2046
|
+
else {
|
|
2047
|
+
throw new DoxloopError('Source type must be directory or openapi.');
|
|
2048
|
+
}
|
|
2049
|
+
sources[index] = source;
|
|
2050
|
+
}
|
|
2051
|
+
if (body.scope !== undefined) {
|
|
2052
|
+
const scope = sourceScopeFromBody(body);
|
|
2053
|
+
if (scope)
|
|
2054
|
+
source = { ...source, scope };
|
|
2055
|
+
else {
|
|
2056
|
+
const { scope: _scope, ...unscoped } = source;
|
|
2057
|
+
source = unscoped;
|
|
2058
|
+
}
|
|
2059
|
+
sources[index] = source;
|
|
2060
|
+
}
|
|
2061
|
+
if (body.remote === null) {
|
|
2062
|
+
const { remote: _remote, ...withoutRemote } = source;
|
|
2063
|
+
sources[index] = withoutRemote;
|
|
2064
|
+
}
|
|
2065
|
+
else if (body.remote !== undefined) {
|
|
2066
|
+
const remote = recordBody(body.remote);
|
|
2067
|
+
const provider = optionalString(remote.provider) === 'github' ? 'github' : 'git';
|
|
2068
|
+
const rawRepository = stringValue(remote.repository);
|
|
2069
|
+
const repository = provider === 'github' ? rawRepository : parseGitRepository(rawRepository);
|
|
2070
|
+
if (provider === 'git')
|
|
2071
|
+
configureGitAccess(remote, repository);
|
|
2072
|
+
if (provider === 'github' && !/^[^/\s]+\/[^/\s]+$/.test(repository))
|
|
2073
|
+
throw new DoxloopError('GitHub repository must use owner/name format.');
|
|
2074
|
+
const branch = stringValue(remote.branch);
|
|
2075
|
+
const nextRemote = {
|
|
2076
|
+
provider,
|
|
2077
|
+
repository,
|
|
2078
|
+
branch,
|
|
2079
|
+
...(optionalString(remote.subdirectory) ? { subdirectory: optionalString(remote.subdirectory) } : {}),
|
|
2080
|
+
...(provider === 'github' && optionalString(remote.tokenEnv) ? { tokenEnv: optionalString(remote.tokenEnv) } : {}),
|
|
2081
|
+
...(provider === 'github' && optionalString(remote.apiBaseUrl) ? { apiBaseUrl: optionalString(remote.apiBaseUrl) } : {}),
|
|
2082
|
+
};
|
|
2083
|
+
const managed = source.path.split(/[\\/]/).includes('.doxloop-sources');
|
|
2084
|
+
const prepared = managed ? await materializeRemoteSource(root, { name: source.name, remote: nextRemote }) : undefined;
|
|
2085
|
+
sources[index] = {
|
|
2086
|
+
...source,
|
|
2087
|
+
...(prepared ? { path: portableSourcePath(root, prepared.path) } : {}),
|
|
2088
|
+
remote: nextRemote,
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
await validateProjectSourceBoundaries(root, sources);
|
|
2092
|
+
await saveProjectSettings(root, { sources });
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Start crawling an existing documentation site. The crawl runs in the
|
|
2096
|
+
* background and the UI polls its progress; the finished snapshot stays in
|
|
2097
|
+
* memory until a source is created from it or the inspection expires.
|
|
2098
|
+
*/
|
|
2099
|
+
async function startDocsSiteInspection(runtime, body) {
|
|
2100
|
+
const url = stringValue(body.url);
|
|
2101
|
+
const { entry } = docsSiteScope(url);
|
|
2102
|
+
const requestedLimit = Number(body.pageLimit);
|
|
2103
|
+
const pageLimit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(MAX_DOCS_CRAWL_PAGE_LIMIT, Math.floor(requestedLimit)) : DEFAULT_DOCS_CRAWL_PAGE_LIMIT;
|
|
2104
|
+
for (const [id, existing] of runtime.docsSiteInspections) {
|
|
2105
|
+
if (Date.now() - Date.parse(existing.startedAt) > DOCS_SITE_INSPECTION_TTL_MS)
|
|
2106
|
+
runtime.docsSiteInspections.delete(id);
|
|
2107
|
+
}
|
|
2108
|
+
const running = [...runtime.docsSiteInspections.values()].filter((item) => item.status === 'running');
|
|
2109
|
+
if (running.length >= 2)
|
|
2110
|
+
throw new DoxloopError('Two documentation sites are already being crawled. Wait for one to finish.');
|
|
2111
|
+
const inspection = {
|
|
2112
|
+
id: randomBytes(8).toString('hex'),
|
|
2113
|
+
url: entry,
|
|
2114
|
+
status: 'running',
|
|
2115
|
+
startedAt: new Date().toISOString(),
|
|
2116
|
+
progress: { fetched: 0, discovered: 0 },
|
|
2117
|
+
controller: new AbortController(),
|
|
2118
|
+
};
|
|
2119
|
+
runtime.docsSiteInspections.set(inspection.id, inspection);
|
|
2120
|
+
const cookieHeader = await docsSiteCookieHeader(runtime, entry);
|
|
2121
|
+
void crawlDocumentationSite(url, {
|
|
2122
|
+
pageLimit,
|
|
2123
|
+
...(cookieHeader ? { cookieHeader } : {}),
|
|
2124
|
+
signal: inspection.controller.signal,
|
|
2125
|
+
onProgress: (progress) => { inspection.progress = { fetched: progress.fetched, discovered: progress.discovered }; },
|
|
2126
|
+
}).then((snapshot) => {
|
|
2127
|
+
inspection.snapshot = snapshot;
|
|
2128
|
+
inspection.status = 'completed';
|
|
2129
|
+
inspection.finishedAt = new Date().toISOString();
|
|
2130
|
+
inspection.progress = { fetched: snapshot.totals.pages + snapshot.skipped.length, discovered: snapshot.totals.discovered };
|
|
2131
|
+
}).catch((error) => {
|
|
2132
|
+
inspection.status = 'failed';
|
|
2133
|
+
inspection.finishedAt = new Date().toISOString();
|
|
2134
|
+
inspection.error = error instanceof Error ? error.message : String(error);
|
|
2135
|
+
});
|
|
2136
|
+
return inspection;
|
|
2137
|
+
}
|
|
2138
|
+
function docsSiteInspectionState(inspection) {
|
|
2139
|
+
const snapshot = inspection.snapshot;
|
|
2140
|
+
return {
|
|
2141
|
+
id: inspection.id,
|
|
2142
|
+
url: inspection.url,
|
|
2143
|
+
status: inspection.status,
|
|
2144
|
+
startedAt: inspection.startedAt,
|
|
2145
|
+
...(inspection.finishedAt ? { finishedAt: inspection.finishedAt } : {}),
|
|
2146
|
+
progress: inspection.progress,
|
|
2147
|
+
...(inspection.error ? { error: inspection.error } : {}),
|
|
2148
|
+
...(snapshot ? {
|
|
2149
|
+
summary: {
|
|
2150
|
+
url: snapshot.url,
|
|
2151
|
+
pages: snapshot.totals.pages,
|
|
2152
|
+
words: snapshot.totals.words,
|
|
2153
|
+
images: snapshot.totals.images,
|
|
2154
|
+
discovered: snapshot.totals.discovered,
|
|
2155
|
+
...(snapshot.generator ? { generator: snapshot.generator } : {}),
|
|
2156
|
+
discovery: snapshot.discovery,
|
|
2157
|
+
truncated: snapshot.truncated,
|
|
2158
|
+
pageLimit: snapshot.pageLimit,
|
|
2159
|
+
skipped: snapshot.skipped.length,
|
|
2160
|
+
brokenLinks: snapshot.brokenLinks.length,
|
|
2161
|
+
warnings: snapshot.warnings,
|
|
2162
|
+
samplePages: snapshot.pages.slice(0, 8).map((page) => ({ path: page.path, title: page.title, words: page.words })),
|
|
2163
|
+
},
|
|
2164
|
+
} : {}),
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
/** The crawl a source is created from: a completed inspection, or a fresh synchronous crawl of the URL. */
|
|
2168
|
+
async function docsSiteSnapshotFromBody(runtime, body) {
|
|
2169
|
+
const inspectionId = optionalString(body.inspectionId);
|
|
2170
|
+
if (inspectionId) {
|
|
2171
|
+
const inspection = runtime.docsSiteInspections.get(inspectionId);
|
|
2172
|
+
if (inspection?.status === 'completed' && inspection.snapshot) {
|
|
2173
|
+
runtime.docsSiteInspections.delete(inspectionId);
|
|
2174
|
+
return inspection.snapshot;
|
|
2175
|
+
}
|
|
2176
|
+
if (inspection?.status === 'running')
|
|
2177
|
+
throw new DoxloopError('The documentation site is still being crawled. Wait for the crawl to finish.');
|
|
2178
|
+
if (inspection?.status === 'failed')
|
|
2179
|
+
throw new DoxloopError(`The documentation site crawl failed: ${inspection.error ?? 'unknown error'}`);
|
|
2180
|
+
}
|
|
2181
|
+
const url = optionalString(body.url) ?? optionalString(body.sourcePath) ?? optionalString(body.path);
|
|
2182
|
+
if (!url)
|
|
2183
|
+
throw new DoxloopError('Enter the address of the existing documentation site.');
|
|
2184
|
+
const cookieHeader = await docsSiteCookieHeader(runtime, url);
|
|
2185
|
+
return crawlDocumentationSite(url, cookieHeader ? { cookieHeader } : {});
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* A documentation site behind the same sign-in as the captured application
|
|
2189
|
+
* reuses the recorded browser session; cookies are matched by domain, so an
|
|
2190
|
+
* unrelated site receives nothing.
|
|
2191
|
+
*/
|
|
2192
|
+
async function docsSiteCookieHeader(runtime, url) {
|
|
2193
|
+
try {
|
|
2194
|
+
if (runtime.root) {
|
|
2195
|
+
const stored = await loadCaptureSession(runtime.root);
|
|
2196
|
+
return sessionCookieHeader(stored?.state, url);
|
|
2197
|
+
}
|
|
2198
|
+
return sessionCookieHeader(runtime.pendingCaptureSession?.state, url);
|
|
2199
|
+
}
|
|
2200
|
+
catch {
|
|
2201
|
+
return undefined;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
/** Re-crawl a documentation site source in place; the new snapshot replaces the binding's path and provenance. */
|
|
2205
|
+
async function refreshDocsSiteSource(runtime, root, name) {
|
|
2206
|
+
const project = await loadProject(root);
|
|
2207
|
+
const index = project.sources.findIndex((source) => source.name === name);
|
|
2208
|
+
const source = project.sources[index];
|
|
2209
|
+
if (!source)
|
|
2210
|
+
throw new DoxloopError(`Source "${name}" does not exist.`);
|
|
2211
|
+
if ((source.kind ?? 'directory') !== 'docs-site' || !source.site)
|
|
2212
|
+
throw new DoxloopError(`Source "${name}" is not a documentation site.`);
|
|
2213
|
+
const cookieHeader = await docsSiteCookieHeader(runtime, source.site.url);
|
|
2214
|
+
const snapshot = await crawlDocumentationSite(source.site.url, cookieHeader ? { cookieHeader } : {});
|
|
2215
|
+
const materialized = await materializeDocsSiteSnapshot(root, name, snapshot);
|
|
2216
|
+
const sources = [...project.sources];
|
|
2217
|
+
sources[index] = docsSiteBinding(root, name, materialized, source.scope);
|
|
2218
|
+
await validateProjectSourceBoundaries(root, sources);
|
|
2219
|
+
await saveProjectSettings(root, { sources });
|
|
2220
|
+
}
|
|
2221
|
+
function sourceScopeFromBody(body) {
|
|
2222
|
+
if (body.scope === undefined || body.scope === null)
|
|
2223
|
+
return undefined;
|
|
2224
|
+
const raw = recordBody(body.scope);
|
|
2225
|
+
const space = optionalString(raw.space);
|
|
2226
|
+
const routePrefix = optionalString(raw.routePrefix);
|
|
2227
|
+
const navigationGroup = optionalString(raw.navigationGroup);
|
|
2228
|
+
const sharedPages = raw.sharedPages === undefined ? undefined : stringArray(raw.sharedPages);
|
|
2229
|
+
if (!space && !routePrefix && !navigationGroup && !sharedPages?.length)
|
|
2230
|
+
return undefined;
|
|
2231
|
+
return { ...(space ? { space } : {}), ...(routePrefix ? { routePrefix } : {}), ...(navigationGroup ? { navigationGroup } : {}), ...(sharedPages?.length ? { sharedPages } : {}) };
|
|
2232
|
+
}
|
|
2233
|
+
async function removeSourceFromUi(root, name) {
|
|
2234
|
+
const project = await loadProject(root);
|
|
2235
|
+
if (!project.sources.some((source) => source.name === name))
|
|
2236
|
+
throw new DoxloopError(`Source "${name}" does not exist.`);
|
|
2237
|
+
const sources = project.sources.filter((source) => source.name !== name);
|
|
2238
|
+
let application = project.application;
|
|
2239
|
+
if (project.application?.source === name) {
|
|
2240
|
+
const { source: _source, startCommand: _startCommand, ...remaining } = project.application;
|
|
2241
|
+
application = remaining;
|
|
2242
|
+
}
|
|
2243
|
+
await saveProjectSettings(root, { sources, application });
|
|
2244
|
+
await setPendingSourceInReceipt(root, name, false);
|
|
2245
|
+
await setPendingRemovedSourceInReceipt(root, name, true);
|
|
2246
|
+
}
|
|
2247
|
+
async function setPendingRemovedSourceInReceipt(root, name, pending) {
|
|
2248
|
+
const path = join(root, '.doxloop', 'last-run.json');
|
|
2249
|
+
const raw = await readOptionalJson(path);
|
|
2250
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
2251
|
+
return;
|
|
2252
|
+
const receipt = { ...raw };
|
|
2253
|
+
const existing = Array.isArray(receipt.pendingRemovedSources)
|
|
2254
|
+
? receipt.pendingRemovedSources.filter((entry) => typeof entry === 'string')
|
|
2255
|
+
: [];
|
|
2256
|
+
const pendingRemovedSources = pending
|
|
2257
|
+
? [...new Set([...existing, name])]
|
|
2258
|
+
: existing.filter((entry) => entry !== name);
|
|
2259
|
+
if (pendingRemovedSources.length > 0)
|
|
2260
|
+
receipt.pendingRemovedSources = pendingRemovedSources;
|
|
2261
|
+
else
|
|
2262
|
+
delete receipt.pendingRemovedSources;
|
|
2263
|
+
await writeFile(path, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
|
|
2264
|
+
}
|
|
2265
|
+
async function clearPendingSourceChangesInReceipt(root) {
|
|
2266
|
+
const path = join(root, '.doxloop', 'last-run.json');
|
|
2267
|
+
const raw = await readOptionalJson(path);
|
|
2268
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
2269
|
+
return;
|
|
2270
|
+
const receipt = { ...raw };
|
|
2271
|
+
delete receipt.pendingSources;
|
|
2272
|
+
delete receipt.pendingRemovedSources;
|
|
2273
|
+
await writeFile(path, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
|
|
2274
|
+
}
|
|
2275
|
+
async function setPendingSourceInReceipt(root, name, pending) {
|
|
2276
|
+
const path = join(root, '.doxloop', 'last-run.json');
|
|
2277
|
+
const raw = await readOptionalJson(path);
|
|
2278
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
2279
|
+
return;
|
|
2280
|
+
const receipt = { ...raw };
|
|
2281
|
+
const existing = Array.isArray(receipt.pendingSources)
|
|
2282
|
+
? receipt.pendingSources.filter((entry) => typeof entry === 'string')
|
|
2283
|
+
: [];
|
|
2284
|
+
const pendingSources = pending
|
|
2285
|
+
? [...new Set([...existing, name])]
|
|
2286
|
+
: existing.filter((entry) => entry !== name);
|
|
2287
|
+
if (pendingSources.length > 0)
|
|
2288
|
+
receipt.pendingSources = pendingSources;
|
|
2289
|
+
else
|
|
2290
|
+
delete receipt.pendingSources;
|
|
2291
|
+
await writeFile(path, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
|
|
2292
|
+
}
|
|
2293
|
+
function syncConfigFromBody(raw, current) {
|
|
2294
|
+
const body = recordBody(raw);
|
|
2295
|
+
const mode = parseSyncMode(optionalString(body.mode) ?? current.mode);
|
|
2296
|
+
const onRaw = body.on === undefined ? current.on : stringArray(body.on);
|
|
2297
|
+
const on = onRaw.length === 0 ? [] : parseTriggerList(onRaw.join(','));
|
|
2298
|
+
const watch = body.watch === undefined ? current.watch : stringArray(body.watch);
|
|
2299
|
+
const ignore = body.ignore === undefined ? current.ignore : stringArray(body.ignore);
|
|
2300
|
+
const branch = optionalString(body.branch);
|
|
2301
|
+
const budgetBody = body.budget === undefined ? undefined : recordBody(body.budget);
|
|
2302
|
+
const maxRunsPerDay = budgetBody ? optionalPositiveNumber(budgetBody.maxRunsPerDay) : current.budget?.maxRunsPerDay;
|
|
2303
|
+
const maxMinutes = budgetBody ? optionalPositiveNumber(budgetBody.maxMinutes) : current.budget?.maxMinutes;
|
|
2304
|
+
const maxUsd = budgetBody ? optionalPositiveAmount(budgetBody.maxUsd) : current.budget?.maxUsd;
|
|
2305
|
+
const budget = maxRunsPerDay || maxMinutes || maxUsd
|
|
2306
|
+
? { ...(maxRunsPerDay ? { maxRunsPerDay } : {}), ...(maxMinutes ? { maxMinutes } : {}), ...(maxUsd ? { maxUsd } : {}) }
|
|
2307
|
+
: undefined;
|
|
2308
|
+
const maxVerificationAgeDays = body.maxVerificationAgeDays === undefined ? current.maxVerificationAgeDays : optionalPositiveNumber(body.maxVerificationAgeDays);
|
|
2309
|
+
const rawSeverity = optionalString(body.maxVerificationAgeSeverity) ?? current.maxVerificationAgeSeverity;
|
|
2310
|
+
if (rawSeverity && rawSeverity !== 'warn' && rawSeverity !== 'fail')
|
|
2311
|
+
throw new DoxloopError('Verification age severity must be warn or fail.');
|
|
2312
|
+
const maxVerificationAgeSeverity = rawSeverity === 'warn' || rawSeverity === 'fail' ? rawSeverity : undefined;
|
|
2313
|
+
return { mode, ...(branch ? { branch } : {}), on, watch, ignore, ...(budget ? { budget } : {}), ...(maxVerificationAgeDays ? { maxVerificationAgeDays } : {}), ...(maxVerificationAgeSeverity ? { maxVerificationAgeSeverity } : {}) };
|
|
2314
|
+
}
|
|
2315
|
+
function documentationFromBody(raw, current) {
|
|
2316
|
+
const body = recordBody(raw);
|
|
2317
|
+
const result = {
|
|
2318
|
+
locale: optionalString(body.locale) ?? current.locale,
|
|
2319
|
+
tone: body.tone === undefined ? current.tone : stringArray(body.tone),
|
|
2320
|
+
standardsProfile: optionalString(body.standardsProfile) ?? current.standardsProfile,
|
|
2321
|
+
styleGuide: optionalString(body.styleGuide) ?? current.styleGuide,
|
|
2322
|
+
terminology: body.terminology === undefined ? current.terminology : stringRecord(body.terminology),
|
|
2323
|
+
exclusions: body.exclusions === undefined ? current.exclusions : stringArray(body.exclusions),
|
|
2324
|
+
accessibilityTarget: optionalString(body.accessibilityTarget) ?? current.accessibilityTarget,
|
|
2325
|
+
};
|
|
2326
|
+
const primaryAudience = optionalString(body.primaryAudience) ?? current.primaryAudience;
|
|
2327
|
+
if (primaryAudience)
|
|
2328
|
+
result.primaryAudience = primaryAudience;
|
|
2329
|
+
const audiences = body.audiences === undefined ? current.audiences : stringArray(body.audiences);
|
|
2330
|
+
if (audiences?.length)
|
|
2331
|
+
result.audiences = audiences;
|
|
2332
|
+
const customInstructions = body.customInstructions === undefined
|
|
2333
|
+
? current.customInstructions
|
|
2334
|
+
: optionalString(body.customInstructions);
|
|
2335
|
+
if (customInstructions)
|
|
2336
|
+
result.customInstructions = customInstructions;
|
|
2337
|
+
const experienceLevel = optionalString(body.experienceLevel) ?? current.experienceLevel;
|
|
2338
|
+
if (experienceLevel === 'beginner' || experienceLevel === 'intermediate' || experienceLevel === 'advanced' || experienceLevel === 'mixed') {
|
|
2339
|
+
result.experienceLevel = experienceLevel;
|
|
2340
|
+
}
|
|
2341
|
+
const priorityOutcomes = body.priorityOutcomes === undefined ? current.priorityOutcomes : stringArray(body.priorityOutcomes);
|
|
2342
|
+
if (priorityOutcomes)
|
|
2343
|
+
result.priorityOutcomes = priorityOutcomes;
|
|
2344
|
+
const preferredExamples = body.preferredExamples === undefined ? current.preferredExamples : stringArray(body.preferredExamples);
|
|
2345
|
+
if (preferredExamples)
|
|
2346
|
+
result.preferredExamples = preferredExamples;
|
|
2347
|
+
const designDirection = body.designDirection === undefined ? current.designDirection : optionalString(body.designDirection);
|
|
2348
|
+
if (designDirection)
|
|
2349
|
+
result.designDirection = designDirection;
|
|
2350
|
+
return result;
|
|
2351
|
+
}
|
|
2352
|
+
export function applicationFromBody(raw, project) {
|
|
2353
|
+
const body = recordBody(raw);
|
|
2354
|
+
const baseUrl = stringValue(body.baseUrl);
|
|
2355
|
+
let parsed;
|
|
2356
|
+
try {
|
|
2357
|
+
parsed = new URL(baseUrl);
|
|
2358
|
+
}
|
|
2359
|
+
catch {
|
|
2360
|
+
throw new DoxloopError('Application URL is invalid.');
|
|
2361
|
+
}
|
|
2362
|
+
// A hash-routed single-page app (`https://host/_/#/`) keeps its routes in
|
|
2363
|
+
// the fragment; any other fragment, a query, or credentials is rejected.
|
|
2364
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || (parsed.hash && !hashRouted(parsed))) {
|
|
2365
|
+
throw new DoxloopError('Application URL must be an http:// or https:// address without a sign-in, query string, or #fragment (a hash route such as #/ is fine).');
|
|
2366
|
+
}
|
|
2367
|
+
const screenshots = body.screenshots === undefined ? undefined : recordBody(body.screenshots);
|
|
2368
|
+
const source = optionalString(body.source);
|
|
2369
|
+
if (source && (!project || !project.sources.some((item) => item.name === source))) {
|
|
2370
|
+
throw new DoxloopError(`Application source "${source}" is not configured.`);
|
|
2371
|
+
}
|
|
2372
|
+
const startCommand = optionalString(body.startCommand);
|
|
2373
|
+
if (startCommand && !source)
|
|
2374
|
+
throw new DoxloopError('A start command requires a product source.');
|
|
2375
|
+
const readyPath = optionalString(body.readyPath);
|
|
2376
|
+
if (readyPath && (!readyPath.startsWith('/') || readyPath.startsWith('//'))) {
|
|
2377
|
+
throw new DoxloopError('Application ready path must start with one slash.');
|
|
2378
|
+
}
|
|
2379
|
+
const policy = optionalString(screenshots?.policy) ?? 'requested';
|
|
2380
|
+
if (!['requested', 'auto', 'off'].includes(policy))
|
|
2381
|
+
throw new DoxloopError('Screenshot policy must be requested, auto, or off.');
|
|
2382
|
+
const width = screenshots ? optionalViewportNumber(screenshots.viewport, 'width', 3840) : undefined;
|
|
2383
|
+
const height = screenshots ? optionalViewportNumber(screenshots.viewport, 'height', 2160) : undefined;
|
|
2384
|
+
if ((width === undefined) !== (height === undefined))
|
|
2385
|
+
throw new DoxloopError('Screenshot viewport requires both width and height.');
|
|
2386
|
+
const startPath = optionalString(screenshots?.startPath);
|
|
2387
|
+
if (startPath && !validApplicationRelativePath(startPath)) {
|
|
2388
|
+
throw new DoxloopError('Screenshot starting route must begin with one slash and must not include a fragment.');
|
|
2389
|
+
}
|
|
2390
|
+
const workflow = optionalString(screenshots?.workflow);
|
|
2391
|
+
if (workflow && workflow.length < 12)
|
|
2392
|
+
throw new DoxloopError('Screenshot workflow guidance must describe the safe state and capture process.');
|
|
2393
|
+
const authentication = body.authentication === undefined || body.authentication === null ? undefined : recordBody(body.authentication);
|
|
2394
|
+
const loginPath = optionalString(authentication?.loginPath);
|
|
2395
|
+
if (loginPath && !validApplicationRelativePath(loginPath)) {
|
|
2396
|
+
throw new DoxloopError('Sign-in route must begin with one slash and must not include a fragment.');
|
|
2397
|
+
}
|
|
2398
|
+
return {
|
|
2399
|
+
baseUrl: parsed.toString().replace(/\/$/, ''),
|
|
2400
|
+
...(source ? { source } : {}),
|
|
2401
|
+
...(startCommand ? { startCommand } : {}),
|
|
2402
|
+
...(readyPath ? { readyPath } : {}),
|
|
2403
|
+
...(loginPath ? { authentication: { loginPath } } : {}),
|
|
2404
|
+
...(screenshots ? {
|
|
2405
|
+
screenshots: {
|
|
2406
|
+
policy: policy,
|
|
2407
|
+
...(width !== undefined && height !== undefined ? { viewport: { width, height } } : {}),
|
|
2408
|
+
...(screenshots.highlight === undefined ? {} : { highlight: screenshots.highlight === true }),
|
|
2409
|
+
...(startPath ? { startPath } : {}),
|
|
2410
|
+
...(workflow ? { workflow } : {}),
|
|
2411
|
+
},
|
|
2412
|
+
} : {}),
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
function validApplicationRelativePath(value) {
|
|
2416
|
+
if (!value.startsWith('/') || value.startsWith('//'))
|
|
2417
|
+
return false;
|
|
2418
|
+
try {
|
|
2419
|
+
const parsed = new URL(value, 'https://application.invalid');
|
|
2420
|
+
return parsed.origin === 'https://application.invalid' && !parsed.hash;
|
|
2421
|
+
}
|
|
2422
|
+
catch {
|
|
2423
|
+
return false;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
function deploymentFromBody(raw) {
|
|
2427
|
+
const body = recordBody(raw);
|
|
2428
|
+
const target = optionalString(body.target);
|
|
2429
|
+
if (target && !['doxbrix', 'github-pages', 'netlify', 'vercel'].includes(target))
|
|
2430
|
+
throw new DoxloopError('Choose Doxbrix, GitHub Pages, Netlify, or Vercel.');
|
|
2431
|
+
const validTarget = target;
|
|
2432
|
+
const visibility = optionalString(body.visibility);
|
|
2433
|
+
if (visibility && visibility !== 'private' && visibility !== 'public')
|
|
2434
|
+
throw new DoxloopError('Visibility must be private or public.');
|
|
2435
|
+
const validVisibility = visibility === 'private' || visibility === 'public' ? visibility : undefined;
|
|
2436
|
+
const rawApiUrl = optionalString(body.apiUrl);
|
|
2437
|
+
let validApiUrl;
|
|
2438
|
+
if (rawApiUrl) {
|
|
2439
|
+
let parsed;
|
|
2440
|
+
try {
|
|
2441
|
+
parsed = new URL(rawApiUrl);
|
|
2442
|
+
}
|
|
2443
|
+
catch {
|
|
2444
|
+
throw new DoxloopError('Deployment API URL is invalid.');
|
|
2445
|
+
}
|
|
2446
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
2447
|
+
throw new DoxloopError('Deployment API URL must be an HTTP(S) origin without credentials, query, or fragment.');
|
|
2448
|
+
}
|
|
2449
|
+
validApiUrl = parsed.toString().replace(/\/$/, '');
|
|
2450
|
+
}
|
|
2451
|
+
return {
|
|
2452
|
+
...(validTarget ? { target: validTarget } : {}),
|
|
2453
|
+
...(optionalString(body.name) ? { name: optionalString(body.name) } : {}),
|
|
2454
|
+
...(optionalString(body.slug) ? { slug: optionalString(body.slug) } : {}),
|
|
2455
|
+
...(validVisibility ? { visibility: validVisibility } : {}),
|
|
2456
|
+
...(validApiUrl ? { apiUrl: validApiUrl } : {}),
|
|
2457
|
+
...(optionalString(body.siteId) ? { siteId: optionalString(body.siteId) } : {}),
|
|
2458
|
+
...(optionalString(body.projectId) ? { projectId: optionalString(body.projectId) } : {}),
|
|
2459
|
+
...(optionalString(body.teamId) ? { teamId: optionalString(body.teamId) } : {}),
|
|
2460
|
+
...(optionalString(body.branch) ? { branch: optionalString(body.branch) } : {}),
|
|
2461
|
+
...(optionalString(body.basePath) ? { basePath: optionalString(body.basePath) } : {}),
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
async function handleProposalPreview(response, url, runtime) {
|
|
2465
|
+
const match = /^\/review-preview\/([a-z0-9-]+)\/(change-\d+)$/.exec(url.pathname);
|
|
2466
|
+
if (!match) {
|
|
2467
|
+
sendJson(response, 404, { error: 'Not found' });
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
const root = requireProject(runtime);
|
|
2471
|
+
const run = await readSyncRun(root, match[1]);
|
|
2472
|
+
const html = await syncReviewComparisonDocument(root, run, requireSyncReviewChange(run, match[2]), {
|
|
2473
|
+
layout: url.searchParams.get('layout') === 'unified' ? 'unified' : 'split',
|
|
2474
|
+
onlyChanges: url.searchParams.get('only') === '1',
|
|
2475
|
+
theme: url.searchParams.get('theme') === 'dark' ? 'dark' : 'light',
|
|
2476
|
+
});
|
|
2477
|
+
send(response, 200, 'text/html; charset=utf-8', html, reviewHeaders());
|
|
2478
|
+
}
|
|
2479
|
+
function startAgentInstallJob(runtime, agent) {
|
|
2480
|
+
const id = randomBytes(8).toString('hex');
|
|
2481
|
+
const displayName = AGENT_CATALOG.find((item) => item.name === agent)?.displayName ?? agent;
|
|
2482
|
+
const job = {
|
|
2483
|
+
id,
|
|
2484
|
+
type: 'agent:install',
|
|
2485
|
+
agent,
|
|
2486
|
+
status: 'running',
|
|
2487
|
+
startedAt: new Date().toISOString(),
|
|
2488
|
+
lastOutputAt: new Date().toISOString(),
|
|
2489
|
+
lines: [`Preparing to install ${displayName}…`],
|
|
2490
|
+
stages: [],
|
|
2491
|
+
};
|
|
2492
|
+
const append = (output) => {
|
|
2493
|
+
const lines = output.split(/\r?\n/).filter(Boolean);
|
|
2494
|
+
if (lines.length === 0)
|
|
2495
|
+
return;
|
|
2496
|
+
const stamped = appendJobLines(job, lines);
|
|
2497
|
+
job.lastOutputAt = new Date().toISOString();
|
|
2498
|
+
queueUiJobLog(runtime, job.id, `${stamped.join('\n')}\n`);
|
|
2499
|
+
publishJobs(runtime);
|
|
2500
|
+
};
|
|
2501
|
+
runtime.jobs.set(id, job);
|
|
2502
|
+
publishJobs(runtime);
|
|
2503
|
+
void installAgent(agent, {
|
|
2504
|
+
onChild: (child) => { job.child = child; },
|
|
2505
|
+
onOutput: append,
|
|
2506
|
+
}).then(({ executable }) => {
|
|
2507
|
+
if (job.status === 'cancelled')
|
|
2508
|
+
return;
|
|
2509
|
+
append(`${displayName} installed successfully at ${executable}.`);
|
|
2510
|
+
job.exitCode = 0;
|
|
2511
|
+
job.status = 'succeeded';
|
|
2512
|
+
}).catch((error) => {
|
|
2513
|
+
if (job.status === 'cancelled')
|
|
2514
|
+
return;
|
|
2515
|
+
append(error instanceof Error ? error.message : String(error));
|
|
2516
|
+
job.exitCode = 1;
|
|
2517
|
+
job.status = 'failed';
|
|
2518
|
+
}).finally(() => {
|
|
2519
|
+
if (!job.finishedAt)
|
|
2520
|
+
job.finishedAt = new Date().toISOString();
|
|
2521
|
+
delete job.child;
|
|
2522
|
+
publishJobs(runtime);
|
|
2523
|
+
});
|
|
2524
|
+
return job;
|
|
2525
|
+
}
|
|
2526
|
+
function planStageForJob(type) {
|
|
2527
|
+
if (type === 'plan:propose')
|
|
2528
|
+
return 'propose';
|
|
2529
|
+
if (type === 'plan:revise')
|
|
2530
|
+
return 'revise';
|
|
2531
|
+
if (type === 'plan:generate' || type === 'plan:continue')
|
|
2532
|
+
return 'generate';
|
|
2533
|
+
return undefined;
|
|
2534
|
+
}
|
|
2535
|
+
/**
|
|
2536
|
+
* A plan job that ends without the CLI recording a result (killed, crashed,
|
|
2537
|
+
* or the UI server restarted underneath it) would leave its plan at
|
|
2538
|
+
* "planning" or "generating" with nothing to retry. Record the interruption
|
|
2539
|
+
* on the plan so the review offers a retry; a plan that already recorded its
|
|
2540
|
+
* own outcome is left as it is.
|
|
2541
|
+
*/
|
|
2542
|
+
/** Jobs that write a proposal run, whose run must not stay "generating" once the job is gone. */
|
|
2543
|
+
const RUN_WRITING_JOB = /^(?:sync$|page-edit:|proposal:(?:resume|revise):|plan:(?:generate|continue)$)/;
|
|
2544
|
+
async function reconcileInterruptedRuns(root, job, message) {
|
|
2545
|
+
if (!root || !RUN_WRITING_JOB.test(job.type))
|
|
2546
|
+
return;
|
|
2547
|
+
try {
|
|
2548
|
+
await markInterruptedSyncRuns(root, job.startedAt, message);
|
|
2549
|
+
}
|
|
2550
|
+
catch {
|
|
2551
|
+
// The job log already records the interruption.
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
async function reconcileInterruptedPlan(root, job, message) {
|
|
2555
|
+
await reconcileInterruptedRuns(root, job, message.replace('Retry the stage to run it again.', 'Resume it from Review to continue in its preserved workspace.'));
|
|
2556
|
+
const stage = planStageForJob(job.type);
|
|
2557
|
+
if (!root || !job.planId || !stage)
|
|
2558
|
+
return;
|
|
2559
|
+
try {
|
|
2560
|
+
await markDocumentationPlanInterrupted(root, job.planId, stage, message);
|
|
2561
|
+
}
|
|
2562
|
+
catch {
|
|
2563
|
+
// The job log already carries the failure; the plan file stays as it was.
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
/** Long agent runs the computer must stay awake for; laptop sleep used to kill their batches. */
|
|
2567
|
+
const KEEP_AWAKE_JOB_TYPES = /^(?:plan:|proposal:(?:resume|revise)|author:|sync$|page-edit:)/;
|
|
2568
|
+
function startCliJob(runtime, type, args, cwd, agent, planId) {
|
|
2569
|
+
const id = randomBytes(8).toString('hex');
|
|
2570
|
+
const retryable = expectedRetryCommand(type) !== undefined;
|
|
2571
|
+
const job = {
|
|
2572
|
+
id,
|
|
2573
|
+
type,
|
|
2574
|
+
...(agent ? { agent } : {}),
|
|
2575
|
+
status: 'running',
|
|
2576
|
+
startedAt: new Date().toISOString(),
|
|
2577
|
+
lines: [],
|
|
2578
|
+
stages: [],
|
|
2579
|
+
...(planId ? { planId } : {}),
|
|
2580
|
+
...(retryable ? { retry: { args: [...args], cwd, ...(agent ? { agent } : {}), ...(planId ? { planId } : {}) } } : {}),
|
|
2581
|
+
};
|
|
2582
|
+
const child = spawn(process.execPath, [CLI_PATH, ...args], {
|
|
2583
|
+
cwd,
|
|
2584
|
+
env: { ...process.env, ...remoteCredentialEnvironment(), ...(type === 'preview' && runtime.controlCenterUrl ? { DOXLOOP_CONTROL_CENTER_URL: runtime.controlCenterUrl } : {}) },
|
|
2585
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2586
|
+
});
|
|
2587
|
+
job.child = child;
|
|
2588
|
+
// Agent output is cleaned (colour codes, stdin notices, Codex internals)
|
|
2589
|
+
// for both views; machine-readable replies are summarized in the live view
|
|
2590
|
+
// only, so the full log keeps what a plan replay needs.
|
|
2591
|
+
const display = new JobLogDisplayFilter();
|
|
2592
|
+
const releaseAwake = KEEP_AWAKE_JOB_TYPES.test(type) ? keepAwake.acquire() : undefined;
|
|
2593
|
+
const appendLines = (raw) => {
|
|
2594
|
+
const lines = cleanJobOutputLines(raw);
|
|
2595
|
+
if (lines.length === 0)
|
|
2596
|
+
return;
|
|
2597
|
+
// Lines are stamped with their arrival time as one unit, so the log file
|
|
2598
|
+
// and the control center agree on when each step happened.
|
|
2599
|
+
const stamped = appendJobLines(job, lines, new Date(), display);
|
|
2600
|
+
for (const line of lines) {
|
|
2601
|
+
applyWorkflowStageLine(job.stages, line);
|
|
2602
|
+
const outcome = parseJobOutcomeLine(line);
|
|
2603
|
+
if (outcome)
|
|
2604
|
+
job.outcome = outcome;
|
|
2605
|
+
}
|
|
2606
|
+
job.lastOutputAt = new Date().toISOString();
|
|
2607
|
+
queueUiJobLog(runtime, job.id, `${stamped.join('\n')}\n`);
|
|
2608
|
+
publishJobs(runtime);
|
|
2609
|
+
};
|
|
2610
|
+
// A chunk can end mid-line; each stream keeps its partial line until the
|
|
2611
|
+
// newline arrives so a timestamp never lands in the middle of a line.
|
|
2612
|
+
const partial = { stdout: '', stderr: '' };
|
|
2613
|
+
const append = (stream, chunk) => {
|
|
2614
|
+
partial[stream] += chunk.toString();
|
|
2615
|
+
const pieces = partial[stream].split(/\r?\n/);
|
|
2616
|
+
partial[stream] = pieces.pop() ?? '';
|
|
2617
|
+
appendLines(pieces.filter(Boolean));
|
|
2618
|
+
};
|
|
2619
|
+
const flush = (stream) => {
|
|
2620
|
+
const rest = partial[stream];
|
|
2621
|
+
partial[stream] = '';
|
|
2622
|
+
if (rest.trim())
|
|
2623
|
+
appendLines([rest]);
|
|
2624
|
+
};
|
|
2625
|
+
child.stdout?.on('data', (chunk) => append('stdout', chunk));
|
|
2626
|
+
child.stderr?.on('data', (chunk) => append('stderr', chunk));
|
|
2627
|
+
child.stdout?.once('end', () => flush('stdout'));
|
|
2628
|
+
child.stderr?.once('end', () => flush('stderr'));
|
|
2629
|
+
const settleDisplay = () => {
|
|
2630
|
+
releaseAwake?.();
|
|
2631
|
+
const pending = display.finish();
|
|
2632
|
+
if (pending.length > 0)
|
|
2633
|
+
job.lines.push(...trimJobLines(pending.map((line) => stampJobLine(line))));
|
|
2634
|
+
};
|
|
2635
|
+
child.once('error', (error) => {
|
|
2636
|
+
flush('stdout');
|
|
2637
|
+
flush('stderr');
|
|
2638
|
+
appendLines([error.message]);
|
|
2639
|
+
settleDisplay();
|
|
2640
|
+
job.status = 'failed';
|
|
2641
|
+
finishWorkflowStages(job.stages, 'failed');
|
|
2642
|
+
job.finishedAt = new Date().toISOString();
|
|
2643
|
+
publishJobs(runtime);
|
|
2644
|
+
});
|
|
2645
|
+
child.once('exit', (code, signal) => {
|
|
2646
|
+
flush('stdout');
|
|
2647
|
+
flush('stderr');
|
|
2648
|
+
settleDisplay();
|
|
2649
|
+
if (job.status === 'cancelled') {
|
|
2650
|
+
void reconcileInterruptedRuns(runtime.root, job, 'The run was stopped before it finished. Resume it from Review to continue in its preserved workspace.');
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
job.exitCode = code ?? 1;
|
|
2654
|
+
job.status = code === 0 && !signal ? 'succeeded' : 'failed';
|
|
2655
|
+
finishWorkflowStages(job.stages, job.status === 'succeeded' ? 'completed' : 'failed');
|
|
2656
|
+
job.finishedAt = new Date().toISOString();
|
|
2657
|
+
delete job.child;
|
|
2658
|
+
publishJobs(runtime);
|
|
2659
|
+
if (job.status === 'failed') {
|
|
2660
|
+
void reconcileInterruptedPlan(runtime.root, job, `The run stopped before it reported a result (${signal ? `signal ${signal}` : `exit code ${code ?? 1}`}). Retry the stage to run it again.`);
|
|
2661
|
+
}
|
|
2662
|
+
});
|
|
2663
|
+
runtime.jobs.set(id, job);
|
|
2664
|
+
publishJobs(runtime);
|
|
2665
|
+
return job;
|
|
2666
|
+
}
|
|
2667
|
+
/**
|
|
2668
|
+
* Ask a CLI job to stop. The CLI forwards the signal to its agent and exits
|
|
2669
|
+
* once the agent is gone; if it does not, the job is killed outright so a
|
|
2670
|
+
* cancelled run can never keep the workspace busy.
|
|
2671
|
+
*/
|
|
2672
|
+
function stopJobChild(child) {
|
|
2673
|
+
if (!child)
|
|
2674
|
+
return;
|
|
2675
|
+
signalTree(child, false, 'SIGTERM');
|
|
2676
|
+
const escalation = setTimeout(() => {
|
|
2677
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
2678
|
+
signalTree(child, false, 'SIGKILL');
|
|
2679
|
+
}, JOB_STOP_GRACE_MS);
|
|
2680
|
+
escalation.unref?.();
|
|
2681
|
+
}
|
|
2682
|
+
function publicJob(job) {
|
|
2683
|
+
const { child: _child, retry, ...rest } = job;
|
|
2684
|
+
return { ...rest, retryable: Boolean(retry) && job.status !== 'running' };
|
|
2685
|
+
}
|
|
2686
|
+
function persistedJob(job) {
|
|
2687
|
+
const { child: _child, ...rest } = job;
|
|
2688
|
+
return rest;
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Whether the assistant that will run is signed in. `unknown` (a slow or odd
|
|
2692
|
+
* status command, or an assistant not on PATH, which the run reports itself)
|
|
2693
|
+
* never blocks. DOXLOOP_SKIP_AGENT_AUTH_CHECK=1 turns the check off.
|
|
2694
|
+
*/
|
|
2695
|
+
async function agentSignInState(agent) {
|
|
2696
|
+
if (process.env.DOXLOOP_SKIP_AGENT_AUTH_CHECK === '1')
|
|
2697
|
+
return undefined;
|
|
2698
|
+
let selected;
|
|
2699
|
+
try {
|
|
2700
|
+
selected = await chooseAgent(agent);
|
|
2701
|
+
}
|
|
2702
|
+
catch {
|
|
2703
|
+
return undefined;
|
|
2704
|
+
}
|
|
2705
|
+
try {
|
|
2706
|
+
return { name: selected.name, status: (await agentAuthenticationStatus(selected)).status };
|
|
2707
|
+
}
|
|
2708
|
+
catch {
|
|
2709
|
+
return { name: selected.name, status: 'unknown' };
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
/** Refuse to start an agent run with a signed-out assistant (HTTP 400). */
|
|
2713
|
+
async function assertAgentSignedIn(agent) {
|
|
2714
|
+
const state = await agentSignInState(agent);
|
|
2715
|
+
if (state?.status === 'unauthenticated')
|
|
2716
|
+
throw new DoxloopError(agentSignedOutMessage(state.name), 2);
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Decide which assistant a plan stage runs with before its job starts. A
|
|
2720
|
+
* body `{ agent?, model?, reasoning?, effort? }` re-points the plan (a new
|
|
2721
|
+
* agent without a model uses that agent's project default, or its own); with
|
|
2722
|
+
* no body, a pinned agent that has signed out gives way to the project's
|
|
2723
|
+
* signed-in default agent. The chosen agent must not be signed out.
|
|
2724
|
+
* Returns the execution change to persist (if any) and a note for the log.
|
|
2725
|
+
*/
|
|
2726
|
+
export async function resolvePlanRunExecution(input) {
|
|
2727
|
+
const { pinned, body, project, signIn } = input;
|
|
2728
|
+
const requestedAgent = parseAgent(optionalString(body.agent));
|
|
2729
|
+
const requestedModel = optionalString(body.model);
|
|
2730
|
+
const requestedReasoning = optionalString(body.reasoning) ? parseReasoning(optionalString(body.reasoning)) : undefined;
|
|
2731
|
+
const requestedEffort = optionalString(body.effort) ? parseClaudeEffort(optionalString(body.effort)) : undefined;
|
|
2732
|
+
if (requestedAgent || requestedModel || requestedReasoning || requestedEffort) {
|
|
2733
|
+
const change = {};
|
|
2734
|
+
if (requestedAgent && requestedAgent !== pinned) {
|
|
2735
|
+
change.agent = requestedAgent;
|
|
2736
|
+
change.model = requestedModel ?? projectDefaultModel(project, requestedAgent);
|
|
2737
|
+
}
|
|
2738
|
+
else if (requestedModel) {
|
|
2739
|
+
change.model = requestedModel;
|
|
2740
|
+
}
|
|
2741
|
+
if (requestedReasoning)
|
|
2742
|
+
change.reasoning = requestedReasoning;
|
|
2743
|
+
if (requestedEffort)
|
|
2744
|
+
change.effort = requestedEffort;
|
|
2745
|
+
const state = await signIn(change.agent ?? pinned);
|
|
2746
|
+
if (state?.status === 'unauthenticated')
|
|
2747
|
+
throw new DoxloopError(agentSignedOutMessage(state.name), 2);
|
|
2748
|
+
return { change };
|
|
2749
|
+
}
|
|
2750
|
+
const state = await signIn(pinned);
|
|
2751
|
+
if (state?.status !== 'unauthenticated')
|
|
2752
|
+
return {};
|
|
2753
|
+
const fallback = project.defaultAgent;
|
|
2754
|
+
if (fallback && fallback !== state.name) {
|
|
2755
|
+
const fallbackState = await signIn(fallback);
|
|
2756
|
+
if (fallbackState?.status === 'authenticated') {
|
|
2757
|
+
return {
|
|
2758
|
+
change: { agent: fallback, model: projectDefaultModel(project, fallback) },
|
|
2759
|
+
note: `${agentDisplayName(state.name)} is signed out, so this run uses ${agentDisplayName(fallback)}, the project's default assistant.`,
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
throw new DoxloopError(agentSignedOutMessage(state.name), 2);
|
|
2764
|
+
}
|
|
2765
|
+
async function preparePlanRunAgent(root, plan, body) {
|
|
2766
|
+
const resolved = await resolvePlanRunExecution({ pinned: plan.execution.agent, body, project: await loadProject(root), signIn: agentSignInState });
|
|
2767
|
+
const next = resolved.change ? await updateDocumentationPlanExecution(root, plan.id, resolved.change) : plan;
|
|
2768
|
+
return { plan: next, ...(resolved.note ? { note: resolved.note } : {}) };
|
|
2769
|
+
}
|
|
2770
|
+
function assertNoActiveDocumentationJob(runtime) {
|
|
2771
|
+
assertNoActiveDocumentationJobs(runtime.jobs.values());
|
|
2772
|
+
}
|
|
2773
|
+
export function assertNoActiveDocumentationJobs(jobs) {
|
|
2774
|
+
const active = [...jobs].find((job) => job.status === 'running' && (job.type.startsWith('author:') || job.type.startsWith('plan:') || job.type.startsWith('proposal:') || job.type.startsWith('page-edit:')));
|
|
2775
|
+
if (active) {
|
|
2776
|
+
throw new DoxloopError('A documentation workflow is already in progress. Wait for it to finish or cancel it before starting another.');
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
function requirePlanFirstAuthoring(mode) {
|
|
2780
|
+
if (mode !== 'review') {
|
|
2781
|
+
throw new DoxloopError('Create and update runs must start from an approved documentation plan in the Doxloop UI.');
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
function projectPreviewIdentity(generator, root) {
|
|
2785
|
+
return generator === 'doxbrix' ? resolve(root) : undefined;
|
|
2786
|
+
}
|
|
2787
|
+
export function previewIdentityMatches(value, expectedRoot) {
|
|
2788
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
2789
|
+
return false;
|
|
2790
|
+
const root = value.root;
|
|
2791
|
+
return typeof root === 'string' && resolve(root) === resolve(expectedRoot);
|
|
2792
|
+
}
|
|
2793
|
+
export async function availableLocalPort() {
|
|
2794
|
+
const probe = createNetServer();
|
|
2795
|
+
probe.unref();
|
|
2796
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
2797
|
+
probe.once('error', rejectListen);
|
|
2798
|
+
probe.listen(0, '127.0.0.1', resolveListen);
|
|
2799
|
+
});
|
|
2800
|
+
const address = probe.address();
|
|
2801
|
+
if (!address || typeof address === 'string') {
|
|
2802
|
+
await new Promise((resolveClose) => probe.close(() => resolveClose()));
|
|
2803
|
+
throw new DoxloopError('Could not allocate a local documentation preview port.');
|
|
2804
|
+
}
|
|
2805
|
+
await new Promise((resolveClose, rejectClose) => {
|
|
2806
|
+
probe.close((error) => error ? rejectClose(error) : resolveClose());
|
|
2807
|
+
});
|
|
2808
|
+
return address.port;
|
|
2809
|
+
}
|
|
2810
|
+
async function waitForPreviewServer(url, job, expectedRoot) {
|
|
2811
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
2812
|
+
if (job.status !== 'running') {
|
|
2813
|
+
throw new DoxloopError(stripJobLineStamp(job.lines.at(-1) ?? '') || 'The documentation preview failed to start.');
|
|
2814
|
+
}
|
|
2815
|
+
try {
|
|
2816
|
+
const response = await fetch(expectedRoot ? `${url}/__doxloop/identity` : url, { cache: 'no-store' });
|
|
2817
|
+
if (expectedRoot) {
|
|
2818
|
+
const identity = await response.json().catch(() => undefined);
|
|
2819
|
+
if (response.ok && previewIdentityMatches(identity, expectedRoot))
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
else {
|
|
2823
|
+
await response.body?.cancel();
|
|
2824
|
+
if (response.ok)
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
catch {
|
|
2829
|
+
// The preview process may still be binding its local port.
|
|
2830
|
+
}
|
|
2831
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
|
|
2832
|
+
}
|
|
2833
|
+
job.child?.kill('SIGTERM');
|
|
2834
|
+
throw new DoxloopError('The documentation preview did not become ready. Try again.');
|
|
2835
|
+
}
|
|
2836
|
+
function streamJobs(request, response, runtime) {
|
|
2837
|
+
response.writeHead(200, {
|
|
2838
|
+
...baseHeaders(),
|
|
2839
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
2840
|
+
Connection: 'keep-alive',
|
|
2841
|
+
});
|
|
2842
|
+
response.write(jobEvent(runtime));
|
|
2843
|
+
runtime.jobSubscribers.add(response);
|
|
2844
|
+
const heartbeat = setInterval(() => response.write(': keepalive\n\n'), 20_000);
|
|
2845
|
+
heartbeat.unref?.();
|
|
2846
|
+
request.once('close', () => {
|
|
2847
|
+
clearInterval(heartbeat);
|
|
2848
|
+
runtime.jobSubscribers.delete(response);
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
function jobEvent(runtime) {
|
|
2852
|
+
const jobs = [...runtime.jobs.values()].map(publicJob).reverse();
|
|
2853
|
+
return `data: ${JSON.stringify(jobs)}\n\n`;
|
|
2854
|
+
}
|
|
2855
|
+
/** Subscribers whose socket is full; they get the newest snapshot once it drains. */
|
|
2856
|
+
const drainingSubscribers = new WeakSet();
|
|
2857
|
+
function broadcastJobs(runtime) {
|
|
2858
|
+
if (runtime.jobBroadcastTimer) {
|
|
2859
|
+
clearTimeout(runtime.jobBroadcastTimer);
|
|
2860
|
+
runtime.jobBroadcastTimer = undefined;
|
|
2861
|
+
}
|
|
2862
|
+
const event = jobEvent(runtime);
|
|
2863
|
+
for (const response of runtime.jobSubscribers) {
|
|
2864
|
+
if (response.destroyed || response.writableEnded) {
|
|
2865
|
+
runtime.jobSubscribers.delete(response);
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
// A tab that cannot keep up would otherwise have every snapshot buffered
|
|
2869
|
+
// in server memory. Every event is a complete snapshot, so skipping this
|
|
2870
|
+
// one and sending the newest after the socket drains loses nothing.
|
|
2871
|
+
if (response.writableNeedDrain) {
|
|
2872
|
+
if (!drainingSubscribers.has(response)) {
|
|
2873
|
+
drainingSubscribers.add(response);
|
|
2874
|
+
response.once('drain', () => {
|
|
2875
|
+
drainingSubscribers.delete(response);
|
|
2876
|
+
if (response.destroyed || response.writableEnded)
|
|
2877
|
+
return;
|
|
2878
|
+
try {
|
|
2879
|
+
response.write(jobEvent(runtime));
|
|
2880
|
+
}
|
|
2881
|
+
catch {
|
|
2882
|
+
runtime.jobSubscribers.delete(response);
|
|
2883
|
+
}
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
continue;
|
|
2887
|
+
}
|
|
2888
|
+
try {
|
|
2889
|
+
response.write(event);
|
|
2890
|
+
}
|
|
2891
|
+
catch {
|
|
2892
|
+
runtime.jobSubscribers.delete(response);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Coalesce job updates. An agent emits hundreds of output chunks per second,
|
|
2898
|
+
* and each one used to serialize the full job list for every subscriber.
|
|
2899
|
+
*/
|
|
2900
|
+
function scheduleJobBroadcast(runtime) {
|
|
2901
|
+
if (runtime.jobBroadcastTimer)
|
|
2902
|
+
return;
|
|
2903
|
+
runtime.jobBroadcastTimer = setTimeout(() => {
|
|
2904
|
+
runtime.jobBroadcastTimer = undefined;
|
|
2905
|
+
broadcastJobs(runtime);
|
|
2906
|
+
}, JOB_BROADCAST_INTERVAL_MS);
|
|
2907
|
+
runtime.jobBroadcastTimer.unref?.();
|
|
2908
|
+
}
|
|
2909
|
+
function trimJobLines(lines) {
|
|
2910
|
+
return lines
|
|
2911
|
+
.slice(-MAX_JOB_LINES)
|
|
2912
|
+
.map((line) => line.length > MAX_JOB_LINE_LENGTH
|
|
2913
|
+
? `${line.slice(0, MAX_JOB_LINE_LENGTH)}… [line truncated; open the full log]`
|
|
2914
|
+
: line);
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Keep new output lines on the job, each stamped with the time it arrived.
|
|
2918
|
+
* Returns the stamped lines in full, for the log file; the job itself keeps
|
|
2919
|
+
* only the recent, length-capped tail.
|
|
2920
|
+
*/
|
|
2921
|
+
function appendJobLines(job, lines, at = new Date(), display) {
|
|
2922
|
+
const stamped = lines.map((line) => stampJobLine(line, at));
|
|
2923
|
+
job.lines.push(...trimJobLines(display ? display.push(stamped) : stamped));
|
|
2924
|
+
if (job.lines.length > MAX_JOB_LINES)
|
|
2925
|
+
job.lines.splice(0, job.lines.length - MAX_JOB_LINES);
|
|
2926
|
+
return stamped;
|
|
2927
|
+
}
|
|
2928
|
+
/** A message from the control center itself, kept on the job and in its log file like agent output. */
|
|
2929
|
+
function noteJob(runtime, job, message) {
|
|
2930
|
+
const [line] = appendJobLines(job, [message]);
|
|
2931
|
+
queueUiJobLog(runtime, job.id, `${line}\n`);
|
|
2932
|
+
}
|
|
2933
|
+
function publishJobs(runtime) {
|
|
2934
|
+
scheduleJobBroadcast(runtime);
|
|
2935
|
+
if (!runtime.root)
|
|
2936
|
+
return;
|
|
2937
|
+
if (runtime.jobPersistTimer)
|
|
2938
|
+
clearTimeout(runtime.jobPersistTimer);
|
|
2939
|
+
runtime.jobPersistTimer = setTimeout(() => {
|
|
2940
|
+
runtime.jobPersistTimer = undefined;
|
|
2941
|
+
void queueUiJobPersistence(runtime);
|
|
2942
|
+
}, 100);
|
|
2943
|
+
runtime.jobPersistTimer.unref?.();
|
|
2944
|
+
}
|
|
2945
|
+
function queueUiJobPersistence(runtime) {
|
|
2946
|
+
runtime.jobPersistQueue = runtime.jobPersistQueue
|
|
2947
|
+
.then(() => persistUiJobs(runtime), () => persistUiJobs(runtime))
|
|
2948
|
+
.catch(() => undefined);
|
|
2949
|
+
return runtime.jobPersistQueue;
|
|
2950
|
+
}
|
|
2951
|
+
function queueUiJobLog(runtime, id, text) {
|
|
2952
|
+
if (!runtime.root || !text)
|
|
2953
|
+
return;
|
|
2954
|
+
const previous = runtime.jobLogQueues.get(id) ?? Promise.resolve();
|
|
2955
|
+
const next = previous
|
|
2956
|
+
.then(async () => {
|
|
2957
|
+
const directory = join(runtime.root, UI_JOB_LOG_DIRECTORY);
|
|
2958
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
2959
|
+
await appendFile(join(directory, `${id}.log`), text, { encoding: 'utf8', mode: 0o600 });
|
|
2960
|
+
})
|
|
2961
|
+
.catch(() => undefined);
|
|
2962
|
+
runtime.jobLogQueues.set(id, next);
|
|
2963
|
+
void next.then(() => {
|
|
2964
|
+
if (runtime.jobLogQueues.get(id) === next)
|
|
2965
|
+
runtime.jobLogQueues.delete(id);
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
async function flushUiJobLogs(runtime) {
|
|
2969
|
+
await Promise.all([...runtime.jobLogQueues.values()]);
|
|
2970
|
+
}
|
|
2971
|
+
async function flushUiJobs(runtime) {
|
|
2972
|
+
if (runtime.jobPersistTimer) {
|
|
2973
|
+
clearTimeout(runtime.jobPersistTimer);
|
|
2974
|
+
runtime.jobPersistTimer = undefined;
|
|
2975
|
+
}
|
|
2976
|
+
await queueUiJobPersistence(runtime);
|
|
2977
|
+
}
|
|
2978
|
+
async function persistUiJobs(runtime) {
|
|
2979
|
+
if (!runtime.root)
|
|
2980
|
+
return;
|
|
2981
|
+
await ensureGitignoreEntries(runtime.root, [UI_JOBS_FILE, `${UI_JOB_LOG_DIRECTORY}/`]);
|
|
2982
|
+
const path = join(runtime.root, UI_JOBS_FILE);
|
|
2983
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
2984
|
+
const jobs = [...runtime.jobs.values()]
|
|
2985
|
+
.slice(-MAX_PERSISTED_JOBS)
|
|
2986
|
+
.map(persistedJob);
|
|
2987
|
+
await mkdir(join(runtime.root, '.doxloop'), { recursive: true, mode: 0o700 });
|
|
2988
|
+
await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, jobs }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
2989
|
+
await rename(temporary, path);
|
|
2990
|
+
}
|
|
2991
|
+
async function loadUiJobs(root) {
|
|
2992
|
+
const raw = await readOptionalJson(join(root, UI_JOBS_FILE));
|
|
2993
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
2994
|
+
return new Map();
|
|
2995
|
+
const record = raw;
|
|
2996
|
+
if (record.schemaVersion !== 1 || !Array.isArray(record.jobs))
|
|
2997
|
+
return new Map();
|
|
2998
|
+
const jobs = record.jobs
|
|
2999
|
+
.map((job) => parsePersistedUiJob(job, root))
|
|
3000
|
+
.filter((job) => job !== undefined)
|
|
3001
|
+
.slice(-MAX_PERSISTED_JOBS);
|
|
3002
|
+
return new Map(jobs.map((job) => [job.id, job]));
|
|
3003
|
+
}
|
|
3004
|
+
function parsePersistedUiJob(raw, root) {
|
|
3005
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
3006
|
+
return undefined;
|
|
3007
|
+
const job = raw;
|
|
3008
|
+
const statuses = ['running', 'succeeded', 'failed', 'cancelled'];
|
|
3009
|
+
if (typeof job.id !== 'string' ||
|
|
3010
|
+
!/^[a-f0-9]+$/.test(job.id) ||
|
|
3011
|
+
typeof job.type !== 'string' ||
|
|
3012
|
+
!statuses.includes(job.status) ||
|
|
3013
|
+
typeof job.startedAt !== 'string' ||
|
|
3014
|
+
!Array.isArray(job.lines))
|
|
3015
|
+
return undefined;
|
|
3016
|
+
const lines = trimJobLines(job.lines.filter((line) => typeof line === 'string'));
|
|
3017
|
+
const retry = job.retry && typeof job.retry === 'object' && !Array.isArray(job.retry)
|
|
3018
|
+
? parseRetry(job.retry, root, job.type)
|
|
3019
|
+
: undefined;
|
|
3020
|
+
return {
|
|
3021
|
+
id: job.id,
|
|
3022
|
+
type: job.type,
|
|
3023
|
+
...(job.agent === 'codex' || job.agent === 'claude' || job.agent === 'gemini'
|
|
3024
|
+
? { agent: job.agent }
|
|
3025
|
+
: {}),
|
|
3026
|
+
status: job.status,
|
|
3027
|
+
startedAt: job.startedAt,
|
|
3028
|
+
...(typeof job.lastOutputAt === 'string' ? { lastOutputAt: job.lastOutputAt } : {}),
|
|
3029
|
+
...(typeof job.finishedAt === 'string' ? { finishedAt: job.finishedAt } : {}),
|
|
3030
|
+
...(typeof job.exitCode === 'number' ? { exitCode: job.exitCode } : {}),
|
|
3031
|
+
...(typeof job.planId === 'string' && /^plan-[a-z0-9-]+$/.test(job.planId) ? { planId: job.planId } : {}),
|
|
3032
|
+
...(retry ? { retry } : {}),
|
|
3033
|
+
...(job.recovered === true ? { recovered: true } : {}),
|
|
3034
|
+
...(parseJobOutcome(job.outcome) ? { outcome: parseJobOutcome(job.outcome) } : {}),
|
|
3035
|
+
lines,
|
|
3036
|
+
stages: Array.isArray(job.stages) ? job.stages.flatMap((stage) => parseWorkflowStage(stage) ?? []) : [],
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
3039
|
+
function parseRetry(value, root, type) {
|
|
3040
|
+
if (!Array.isArray(value.args) || typeof value.cwd !== 'string')
|
|
3041
|
+
return undefined;
|
|
3042
|
+
const args = value.args.filter((item) => typeof item === 'string').slice(0, 100);
|
|
3043
|
+
if (args.length !== value.args.length)
|
|
3044
|
+
return undefined;
|
|
3045
|
+
const expected = expectedRetryCommand(type);
|
|
3046
|
+
if (!expected || args[0] !== expected)
|
|
3047
|
+
return undefined;
|
|
3048
|
+
const cwd = resolve(value.cwd);
|
|
3049
|
+
if (!containedBy(root, cwd))
|
|
3050
|
+
return undefined;
|
|
3051
|
+
const cwdIndex = args.lastIndexOf('--cwd');
|
|
3052
|
+
if (cwdIndex < 0 || !args[cwdIndex + 1] || !containedBy(root, resolve(cwd, args[cwdIndex + 1])))
|
|
3053
|
+
return undefined;
|
|
3054
|
+
return { args, cwd, ...(value.agent === 'codex' || value.agent === 'claude' || value.agent === 'gemini' ? { agent: value.agent } : {}), ...(typeof value.planId === 'string' ? { planId: value.planId } : {}) };
|
|
3055
|
+
}
|
|
3056
|
+
function expectedRetryCommand(type) {
|
|
3057
|
+
if (type.startsWith('plan:'))
|
|
3058
|
+
return 'plan';
|
|
3059
|
+
if (type.startsWith('proposal:revise:') || type.startsWith('proposal:resume:'))
|
|
3060
|
+
return 'proposal';
|
|
3061
|
+
if (type.startsWith('page-edit:'))
|
|
3062
|
+
return 'pages';
|
|
3063
|
+
if (type === 'author:review')
|
|
3064
|
+
return 'review';
|
|
3065
|
+
return ['sync', 'capture', 'generator', 'deploy', 'deploy:dry-run', 'export'].includes(type) ? type.split(':')[0] : undefined;
|
|
3066
|
+
}
|
|
3067
|
+
function containedBy(root, candidate) {
|
|
3068
|
+
const rel = relative(resolve(root), resolve(candidate));
|
|
3069
|
+
return rel !== '..' && !rel.startsWith(`..${sep}`);
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* The readiness endpoints also load the page in the headless browser, since a
|
|
3073
|
+
* single-page app answers 200 and only then sends the visitor to sign in.
|
|
3074
|
+
* DOXLOOP_READINESS_BROWSER_PROBE=0 turns the browser visit off.
|
|
3075
|
+
*/
|
|
3076
|
+
function readinessProbeOptions(context) {
|
|
3077
|
+
return { browserProbe: process.env.DOXLOOP_READINESS_BROWSER_PROBE !== '0', context };
|
|
3078
|
+
}
|
|
3079
|
+
export function uiErrorStatus(error) {
|
|
3080
|
+
return error instanceof DoxloopError ? (error.exitCode === 2 ? 400 : 409) : 500;
|
|
3081
|
+
}
|
|
3082
|
+
/** The release-notes template a plan request carries, validated as a bad request. */
|
|
3083
|
+
export function releaseTemplateFromBody(raw) {
|
|
3084
|
+
if (raw === undefined || raw === null)
|
|
3085
|
+
return undefined;
|
|
3086
|
+
const body = recordBody(raw);
|
|
3087
|
+
if (body.kind !== 'release-notes')
|
|
3088
|
+
throw new DoxloopError('Only the release-notes template is available.', 2);
|
|
3089
|
+
const version = optionalString(body.version);
|
|
3090
|
+
const from = optionalString(body.from);
|
|
3091
|
+
const to = optionalString(body.to);
|
|
3092
|
+
if (!version)
|
|
3093
|
+
throw new DoxloopError('Release notes need a version label, such as v0.2.0.', 2);
|
|
3094
|
+
if (!from || !to)
|
|
3095
|
+
throw new DoxloopError('Release notes need the starting and ending Git refs.', 2);
|
|
3096
|
+
const sources = stringArray(body.sources).map((name) => name.trim()).filter(Boolean);
|
|
3097
|
+
return { version, from, to, ...(sources.length ? { sources } : {}) };
|
|
3098
|
+
}
|
|
3099
|
+
export function pageEditJobSpec(root, project, runId, rawBody) {
|
|
3100
|
+
const body = recordBody(rawBody);
|
|
3101
|
+
const paths = [...new Set(stringArray(body.paths).map((path) => path.trim()).filter(Boolean))];
|
|
3102
|
+
const instruction = optionalString(body.instruction)?.trim() ?? '';
|
|
3103
|
+
if (instruction.length < 8)
|
|
3104
|
+
throw new DoxloopError('Describe what should change.', 2);
|
|
3105
|
+
if (paths.length < 1 || paths.length > 10)
|
|
3106
|
+
throw new DoxloopError('Select between 1 and 10 pages to edit.', 2);
|
|
3107
|
+
const requestedAgent = parseAgent(optionalString(body.agent));
|
|
3108
|
+
const effectiveAgent = requestedAgent ?? project.defaultAgent;
|
|
3109
|
+
const screenshots = optionalString(body.screenshots) ?? 'disabled';
|
|
3110
|
+
if (screenshots !== 'enabled' && screenshots !== 'disabled')
|
|
3111
|
+
throw new DoxloopError('Screenshots must be enabled or disabled.', 2);
|
|
3112
|
+
const args = ['pages', 'edit', '--run-id', runId, '--request', instruction];
|
|
3113
|
+
for (const path of paths)
|
|
3114
|
+
args.push('--path', path);
|
|
3115
|
+
if (body.allowRelated === true)
|
|
3116
|
+
args.push('--allow-related');
|
|
3117
|
+
if (screenshots === 'enabled')
|
|
3118
|
+
args.push('--screenshots');
|
|
3119
|
+
appendOption(args, 'agent', requestedAgent);
|
|
3120
|
+
appendOption(args, 'model', optionalString(body.model) ?? projectDefaultModel(project, effectiveAgent));
|
|
3121
|
+
if (effectiveAgent === 'codex')
|
|
3122
|
+
appendOption(args, 'reasoning', parseReasoning(optionalString(body.reasoning)));
|
|
3123
|
+
if (effectiveAgent === 'claude')
|
|
3124
|
+
appendOption(args, 'effort', parseClaudeEffort(optionalString(body.effort)));
|
|
3125
|
+
args.push('--cwd', root);
|
|
3126
|
+
return {
|
|
3127
|
+
type: `page-edit:${runId}`,
|
|
3128
|
+
args,
|
|
3129
|
+
paths,
|
|
3130
|
+
allowRelated: body.allowRelated === true,
|
|
3131
|
+
...(effectiveAgent ? { agent: effectiveAgent } : {}),
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
3134
|
+
export function pageRefineJobSpec(root, run, rawBody) {
|
|
3135
|
+
if (!run.editRequest)
|
|
3136
|
+
throw new DoxloopError('Only a page edit can be refined from the Pages view.', 2);
|
|
3137
|
+
const instruction = optionalString(recordBody(rawBody).instruction)?.trim() ?? '';
|
|
3138
|
+
if (instruction.length < 8)
|
|
3139
|
+
throw new DoxloopError('Describe what should be different.', 2);
|
|
3140
|
+
const args = ['proposal', 'revise', '--id', run.id, '--request', instruction];
|
|
3141
|
+
for (const change of run.changes.filter((change) => change.category === 'page'))
|
|
3142
|
+
args.push('--change', change.id);
|
|
3143
|
+
args.push('--cwd', root);
|
|
3144
|
+
return { type: `proposal:revise:${run.id}`, args };
|
|
3145
|
+
}
|
|
3146
|
+
async function optionalProjectRoot(cwd) {
|
|
3147
|
+
try {
|
|
3148
|
+
return await findProjectRoot(resolve(cwd));
|
|
3149
|
+
}
|
|
3150
|
+
catch {
|
|
3151
|
+
return undefined;
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
/** History pages are read on demand, so the page size stays small and bounded. */
|
|
3155
|
+
function historyLimit(raw) {
|
|
3156
|
+
// `Number(null)` and `Number('')` are both 0, so an absent parameter has to be
|
|
3157
|
+
// rejected before the numeric check or it would clamp the page down to 1 row.
|
|
3158
|
+
if (raw === null || raw.trim() === '')
|
|
3159
|
+
return 20;
|
|
3160
|
+
const parsed = Number(raw);
|
|
3161
|
+
if (!Number.isFinite(parsed))
|
|
3162
|
+
return 20;
|
|
3163
|
+
return Math.min(200, Math.max(1, Math.trunc(parsed)));
|
|
3164
|
+
}
|
|
3165
|
+
function requireProject(runtime) {
|
|
3166
|
+
if (!runtime.root)
|
|
3167
|
+
throw new DoxloopError('Create or open a Doxloop project first.');
|
|
3168
|
+
return runtime.root;
|
|
3169
|
+
}
|
|
3170
|
+
async function readJsonBody(request, limit = MAX_BODY_BYTES) {
|
|
3171
|
+
const chunks = [];
|
|
3172
|
+
let size = 0;
|
|
3173
|
+
for await (const chunk of request) {
|
|
3174
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3175
|
+
size += value.length;
|
|
3176
|
+
if (size > limit)
|
|
3177
|
+
throw new DoxloopError('The UI request is too large.');
|
|
3178
|
+
chunks.push(value);
|
|
3179
|
+
}
|
|
3180
|
+
try {
|
|
3181
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
3182
|
+
}
|
|
3183
|
+
catch {
|
|
3184
|
+
throw new DoxloopError('The UI request is not valid JSON.');
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
async function serveStatic(response, root, relativePath, html) {
|
|
3188
|
+
const path = resolve(root, relativePath.replace(/^ui\//, ''));
|
|
3189
|
+
if (path !== root && !path.startsWith(`${root}${sep}`))
|
|
3190
|
+
throw new DoxloopError('Invalid UI asset path.');
|
|
3191
|
+
await serveFile(response, path, html);
|
|
3192
|
+
}
|
|
3193
|
+
async function serveFile(response, path, html) {
|
|
3194
|
+
await access(path);
|
|
3195
|
+
const type = html ? 'text/html; charset=utf-8' : mimeType(path);
|
|
3196
|
+
send(response, 200, type, await readFile(path), html ? appHeaders() : staticHeaders());
|
|
3197
|
+
}
|
|
3198
|
+
function requireLocalHost(request, port) {
|
|
3199
|
+
const host = request.headers.host?.toLowerCase();
|
|
3200
|
+
const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
3201
|
+
if (!host || !allowed.has(host))
|
|
3202
|
+
throw new DoxloopError('Invalid local UI host.');
|
|
3203
|
+
}
|
|
3204
|
+
export function uiSessionCookieName(port) {
|
|
3205
|
+
return `doxloop_ui_${port}`;
|
|
3206
|
+
}
|
|
3207
|
+
function requireSession(request, session, port) {
|
|
3208
|
+
const cookie = request.headers.cookie ?? '';
|
|
3209
|
+
const expected = `${uiSessionCookieName(port)}=${session}`;
|
|
3210
|
+
const valid = cookie.split(';').some((entry) => entry.trim() === expected);
|
|
3211
|
+
if (!valid)
|
|
3212
|
+
throw new DoxloopError('Invalid local UI session. Reload the Doxloop UI.');
|
|
3213
|
+
}
|
|
3214
|
+
function requireSameOrigin(request, port) {
|
|
3215
|
+
const origin = request.headers.origin;
|
|
3216
|
+
if (origin !== `http://127.0.0.1:${port}` && origin !== `http://localhost:${port}`) {
|
|
3217
|
+
throw new DoxloopError('Invalid local UI origin.');
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
function appHeaders() {
|
|
3221
|
+
return {
|
|
3222
|
+
...baseHeaders(),
|
|
3223
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-src 'self' http://127.0.0.1:*; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
3226
|
+
function reviewHeaders() {
|
|
3227
|
+
return { ...baseHeaders(), 'X-Frame-Options': 'SAMEORIGIN', 'Content-Security-Policy': "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'self'" };
|
|
3228
|
+
}
|
|
3229
|
+
function staticHeaders() {
|
|
3230
|
+
return { ...baseHeaders(), 'Cache-Control': 'no-cache' };
|
|
3231
|
+
}
|
|
3232
|
+
function baseHeaders() {
|
|
3233
|
+
return {
|
|
3234
|
+
'Cache-Control': 'no-store',
|
|
3235
|
+
'X-Content-Type-Options': 'nosniff',
|
|
3236
|
+
'Referrer-Policy': 'no-referrer',
|
|
3237
|
+
'X-Frame-Options': 'DENY',
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
/**
|
|
3241
|
+
* The browser only needs a hunk's review state; the diff text is fetched per
|
|
3242
|
+
* file from the diff endpoint. Sending every hunk body made /api/state about
|
|
3243
|
+
* 1 MB for a 50-page proposal, which the UI re-reads after every action.
|
|
3244
|
+
*/
|
|
3245
|
+
function uiProposal(run) {
|
|
3246
|
+
return {
|
|
3247
|
+
...run,
|
|
3248
|
+
changes: run.changes.map((change) => ({
|
|
3249
|
+
...change,
|
|
3250
|
+
hunks: change.hunks.map(({ id, acceptedAt, rejectedAt, rejectionReason }) => ({
|
|
3251
|
+
id,
|
|
3252
|
+
...(acceptedAt ? { acceptedAt } : {}),
|
|
3253
|
+
...(rejectedAt ? { rejectedAt } : {}),
|
|
3254
|
+
...(rejectionReason ? { rejectionReason } : {}),
|
|
3255
|
+
})),
|
|
3256
|
+
})),
|
|
3257
|
+
};
|
|
3258
|
+
}
|
|
3259
|
+
/**
|
|
3260
|
+
* List the PNGs a run has captured. The manifest supplies the reviewable
|
|
3261
|
+
* metadata, but images are also listed before the manifest exists so a live run
|
|
3262
|
+
* shows its captures as they land.
|
|
3263
|
+
*/
|
|
3264
|
+
async function listRunCaptures(root, requested) {
|
|
3265
|
+
const runs = await listSyncRuns(root);
|
|
3266
|
+
const target = requested
|
|
3267
|
+
? runs.find((run) => run.id === requested)
|
|
3268
|
+
: runs.find((run) => Boolean(run.id));
|
|
3269
|
+
if (!target)
|
|
3270
|
+
return [];
|
|
3271
|
+
const workspace = runWorkspace(root, target.id);
|
|
3272
|
+
if (!(await pathExists(workspace)))
|
|
3273
|
+
return [];
|
|
3274
|
+
const described = new Map();
|
|
3275
|
+
try {
|
|
3276
|
+
const manifest = JSON.parse(await readFile(join(workspace, '.doxloop', 'screenshot-manifest.json'), 'utf8'));
|
|
3277
|
+
for (const guide of manifest.guides ?? []) {
|
|
3278
|
+
for (const step of guide.steps ?? []) {
|
|
3279
|
+
const file = typeof step.file === 'string' ? step.file : undefined;
|
|
3280
|
+
if (!file)
|
|
3281
|
+
continue;
|
|
3282
|
+
described.set(file, {
|
|
3283
|
+
run: target.id,
|
|
3284
|
+
file,
|
|
3285
|
+
url: `/api/captures/file?run=${encodeURIComponent(target.id)}&path=${encodeURIComponent(file)}`,
|
|
3286
|
+
...(guide.page ? { guide: guide.page } : {}),
|
|
3287
|
+
...(typeof step.id === 'string' ? { step: step.id } : {}),
|
|
3288
|
+
...(typeof step.alt === 'string' ? { alt: step.alt } : {}),
|
|
3289
|
+
...(typeof step.status === 'string' ? { status: step.status } : {}),
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
catch {
|
|
3295
|
+
// A run that has not written its manifest yet still shows its images.
|
|
3296
|
+
}
|
|
3297
|
+
for (const file of await guideImageFiles(workspace)) {
|
|
3298
|
+
if (described.has(file))
|
|
3299
|
+
continue;
|
|
3300
|
+
described.set(file, {
|
|
3301
|
+
run: target.id,
|
|
3302
|
+
file,
|
|
3303
|
+
url: `/api/captures/file?run=${encodeURIComponent(target.id)}&path=${encodeURIComponent(file)}`,
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
const seen = new Map();
|
|
3307
|
+
const captures = [];
|
|
3308
|
+
for (const capture of [...described.values()].sort((left, right) => left.file.localeCompare(right.file))) {
|
|
3309
|
+
const image = join(workspace, capture.file);
|
|
3310
|
+
if (!(await pathExists(image)))
|
|
3311
|
+
continue;
|
|
3312
|
+
const digest = createHash('sha256').update(await readFile(image)).digest('hex');
|
|
3313
|
+
const original = seen.get(digest);
|
|
3314
|
+
if (original)
|
|
3315
|
+
capture.duplicateOf = original;
|
|
3316
|
+
else
|
|
3317
|
+
seen.set(digest, capture.file);
|
|
3318
|
+
captures.push(capture);
|
|
3319
|
+
}
|
|
3320
|
+
return captures;
|
|
3321
|
+
}
|
|
3322
|
+
/** Screenshots a planning run took while exploring the application. */
|
|
3323
|
+
function planCaptureDirectory(root, planId) {
|
|
3324
|
+
if (!/^plan-[a-z0-9-]+$/.test(planId))
|
|
3325
|
+
throw new DoxloopError('That plan does not exist.');
|
|
3326
|
+
return join(root, '.doxloop', 'plans', planId, 'captures');
|
|
3327
|
+
}
|
|
3328
|
+
async function listPlanCaptures(root, planId) {
|
|
3329
|
+
const directory = planCaptureDirectory(root, planId);
|
|
3330
|
+
let files;
|
|
3331
|
+
try {
|
|
3332
|
+
files = (await readdir(directory)).filter((file) => /\.png$/i.test(file)).sort();
|
|
3333
|
+
}
|
|
3334
|
+
catch {
|
|
3335
|
+
return [];
|
|
3336
|
+
}
|
|
3337
|
+
return files.map((file) => ({
|
|
3338
|
+
run: '',
|
|
3339
|
+
file,
|
|
3340
|
+
url: `/api/captures/file?plan=${encodeURIComponent(planId)}&path=${encodeURIComponent(file)}`,
|
|
3341
|
+
alt: file.replace(/\.png$/i, '').replace(/[-_]+/g, ' '),
|
|
3342
|
+
}));
|
|
3343
|
+
}
|
|
3344
|
+
/** Project-relative PNGs under any guide asset directory. */
|
|
3345
|
+
async function guideImageFiles(workspace) {
|
|
3346
|
+
const files = [];
|
|
3347
|
+
const stack = [''];
|
|
3348
|
+
while (stack.length > 0 && files.length < 500) {
|
|
3349
|
+
const relativeDirectory = stack.pop();
|
|
3350
|
+
let entries;
|
|
3351
|
+
try {
|
|
3352
|
+
entries = await readdir(join(workspace, relativeDirectory), { withFileTypes: true });
|
|
3353
|
+
}
|
|
3354
|
+
catch {
|
|
3355
|
+
continue;
|
|
3356
|
+
}
|
|
3357
|
+
for (const entry of entries) {
|
|
3358
|
+
const child = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
|
|
3359
|
+
if (entry.isDirectory()) {
|
|
3360
|
+
if (!['.doxloop', '.git', 'node_modules'].includes(entry.name))
|
|
3361
|
+
stack.push(child);
|
|
3362
|
+
}
|
|
3363
|
+
else if (/\.png$/i.test(entry.name) && child.includes('guides/')) {
|
|
3364
|
+
files.push(child);
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
return files;
|
|
3369
|
+
}
|
|
3370
|
+
function sendJson(response, status, value) {
|
|
3371
|
+
send(response, status, 'application/json; charset=utf-8', JSON.stringify(value), baseHeaders());
|
|
3372
|
+
}
|
|
3373
|
+
function send(response, status, type, body, headers) {
|
|
3374
|
+
if (response.headersSent)
|
|
3375
|
+
return;
|
|
3376
|
+
response.writeHead(status, { 'Content-Type': type, ...headers });
|
|
3377
|
+
response.end(body);
|
|
3378
|
+
}
|
|
3379
|
+
function mimeType(path) {
|
|
3380
|
+
switch (extname(path)) {
|
|
3381
|
+
case '.js': return 'text/javascript; charset=utf-8';
|
|
3382
|
+
case '.css': return 'text/css; charset=utf-8';
|
|
3383
|
+
case '.svg': return 'image/svg+xml';
|
|
3384
|
+
case '.png': return 'image/png';
|
|
3385
|
+
case '.map': return 'application/json; charset=utf-8';
|
|
3386
|
+
default: return 'application/octet-stream';
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
/** The URL segment to open. Older page names still work and land on the renamed page. */
|
|
3390
|
+
export function normalizeInitialPage(value) {
|
|
3391
|
+
const page = value ?? 'overview';
|
|
3392
|
+
const legacy = { authoring: 'update', proposals: 'review', publish: 'deploy' };
|
|
3393
|
+
const allowed = new Set(['overview', 'sources', 'update', 'pages', 'review', 'deploy', 'settings']);
|
|
3394
|
+
const resolved = legacy[page] ?? page;
|
|
3395
|
+
if (!allowed.has(resolved))
|
|
3396
|
+
throw new DoxloopError(`Unknown UI page "${page}".`);
|
|
3397
|
+
return resolved;
|
|
3398
|
+
}
|
|
3399
|
+
function openBrowser(url) {
|
|
3400
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
3401
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
3402
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
3403
|
+
child.unref();
|
|
3404
|
+
}
|
|
3405
|
+
async function chooseLocalDirectory(prompt) {
|
|
3406
|
+
// Prompts are fixed strings chosen by the server, never request input.
|
|
3407
|
+
const picker = process.platform === 'darwin'
|
|
3408
|
+
? { command: 'osascript', args: ['-e', `POSIX path of (choose folder with prompt "${prompt}")`] }
|
|
3409
|
+
: process.platform === 'win32'
|
|
3410
|
+
? {
|
|
3411
|
+
command: 'powershell.exe',
|
|
3412
|
+
args: ['-NoProfile', '-STA', '-Command', `Add-Type -AssemblyName System.Windows.Forms; $picker = New-Object System.Windows.Forms.FolderBrowserDialog; $picker.Description = "${prompt}"; if ($picker.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $picker.SelectedPath }`],
|
|
3413
|
+
}
|
|
3414
|
+
: { command: 'zenity', args: ['--file-selection', '--directory', `--title=${prompt}`] };
|
|
3415
|
+
return new Promise((resolvePicker, rejectPicker) => {
|
|
3416
|
+
const child = spawn(picker.command, picker.args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
3417
|
+
let output = '';
|
|
3418
|
+
let detail = '';
|
|
3419
|
+
child.stdout.on('data', (chunk) => { output += chunk.toString('utf8'); });
|
|
3420
|
+
child.stderr.on('data', (chunk) => { detail += chunk.toString('utf8'); });
|
|
3421
|
+
child.once('error', (error) => rejectPicker(new DoxloopError(`Could not open the directory picker: ${error.message}`)));
|
|
3422
|
+
child.once('exit', (code) => {
|
|
3423
|
+
if (code === 0) {
|
|
3424
|
+
const selected = output.trim();
|
|
3425
|
+
resolvePicker(selected ? resolve(selected) : undefined);
|
|
3426
|
+
return;
|
|
3427
|
+
}
|
|
3428
|
+
if (code === 1 || detail.toLowerCase().includes('cancel')) {
|
|
3429
|
+
resolvePicker(undefined);
|
|
3430
|
+
return;
|
|
3431
|
+
}
|
|
3432
|
+
rejectPicker(new DoxloopError(`The directory picker failed${detail.trim() ? `: ${detail.trim()}` : ` with exit code ${code ?? 1}`}.`));
|
|
3433
|
+
});
|
|
3434
|
+
});
|
|
3435
|
+
}
|
|
3436
|
+
function appendOption(args, name, value) {
|
|
3437
|
+
if (value)
|
|
3438
|
+
args.push(`--${name}`, value);
|
|
3439
|
+
}
|
|
3440
|
+
function recordBody(value) {
|
|
3441
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
3442
|
+
throw new DoxloopError('Expected an object.');
|
|
3443
|
+
return value;
|
|
3444
|
+
}
|
|
3445
|
+
function sourceIdentifier(label) {
|
|
3446
|
+
const identifier = label.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
3447
|
+
return identifier || 'source';
|
|
3448
|
+
}
|
|
3449
|
+
function rawText(value) {
|
|
3450
|
+
if (typeof value !== 'string')
|
|
3451
|
+
throw new DoxloopError('Page content must be text.');
|
|
3452
|
+
return value;
|
|
3453
|
+
}
|
|
3454
|
+
function stringValue(value) {
|
|
3455
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
3456
|
+
throw new DoxloopError('A required value is missing.');
|
|
3457
|
+
return value.trim();
|
|
3458
|
+
}
|
|
3459
|
+
function optionalString(value) {
|
|
3460
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
3461
|
+
}
|
|
3462
|
+
function isNodeError(error) {
|
|
3463
|
+
return error instanceof Error && 'code' in error;
|
|
3464
|
+
}
|
|
3465
|
+
function stringArray(value) {
|
|
3466
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))
|
|
3467
|
+
throw new DoxloopError('Expected a list of text values.');
|
|
3468
|
+
return value.map((entry) => entry.trim()).filter(Boolean);
|
|
3469
|
+
}
|
|
3470
|
+
function stringRecord(value) {
|
|
3471
|
+
const record = recordBody(value);
|
|
3472
|
+
const output = {};
|
|
3473
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
3474
|
+
if (typeof entry !== 'string')
|
|
3475
|
+
throw new DoxloopError('Terminology values must be text.');
|
|
3476
|
+
if (key.trim() && entry.trim())
|
|
3477
|
+
output[key.trim()] = entry.trim();
|
|
3478
|
+
}
|
|
3479
|
+
return output;
|
|
3480
|
+
}
|
|
3481
|
+
function optionalPositiveNumber(value) {
|
|
3482
|
+
if (value === undefined || value === null || value === '')
|
|
3483
|
+
return undefined;
|
|
3484
|
+
const number = Number(value);
|
|
3485
|
+
if (!Number.isInteger(number) || number < 1)
|
|
3486
|
+
throw new DoxloopError('Budget values must be positive integers.');
|
|
3487
|
+
return number;
|
|
3488
|
+
}
|
|
3489
|
+
function optionalPositiveAmount(value) {
|
|
3490
|
+
if (value === undefined || value === null || value === '')
|
|
3491
|
+
return undefined;
|
|
3492
|
+
const number = Number(value);
|
|
3493
|
+
if (!Number.isFinite(number) || number <= 0 || number > 100_000)
|
|
3494
|
+
throw new DoxloopError('The spending cap must be a positive amount in US dollars.');
|
|
3495
|
+
return Math.round(number * 100) / 100;
|
|
3496
|
+
}
|
|
3497
|
+
function optionalViewportNumber(value, key, maximum) {
|
|
3498
|
+
if (value === undefined || value === null)
|
|
3499
|
+
return undefined;
|
|
3500
|
+
const record = recordBody(value);
|
|
3501
|
+
const raw = record[key];
|
|
3502
|
+
if (raw === undefined || raw === null || raw === '')
|
|
3503
|
+
return undefined;
|
|
3504
|
+
const number = Number(raw);
|
|
3505
|
+
if (!Number.isInteger(number) || number < 320 || number > maximum) {
|
|
3506
|
+
throw new DoxloopError(`Screenshot viewport ${key} must be an integer between 320 and ${maximum}.`);
|
|
3507
|
+
}
|
|
3508
|
+
return number;
|
|
3509
|
+
}
|
|
3510
|
+
function portableRelative(from, to) {
|
|
3511
|
+
return relative(from, to).split(sep).join('/');
|
|
3512
|
+
}
|
|
3513
|
+
async function safe(run) {
|
|
3514
|
+
try {
|
|
3515
|
+
return await run();
|
|
3516
|
+
}
|
|
3517
|
+
catch (error) {
|
|
3518
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
function isFailure(value) {
|
|
3522
|
+
return Boolean(value && typeof value === 'object' && 'error' in value);
|
|
3523
|
+
}
|
|
3524
|
+
async function readOptionalJson(path) {
|
|
3525
|
+
try {
|
|
3526
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
3527
|
+
}
|
|
3528
|
+
catch {
|
|
3529
|
+
return null;
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
//# sourceMappingURL=ui-server.js.map
|