@supportpages.io/wtfm 0.0.0-stage → 0.3.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/LICENSE +201 -0
- package/NOTICE +23 -0
- package/README.md +392 -2
- package/dist/actions.d.ts +98 -0
- package/dist/actions.js +86 -0
- package/dist/actions.js.map +1 -0
- package/dist/agent-settings.d.ts +22 -0
- package/dist/agent-settings.js +35 -0
- package/dist/agent-settings.js.map +1 -0
- package/dist/api.d.ts +14 -0
- package/dist/api.js +159 -0
- package/dist/api.js.map +1 -0
- package/dist/article-link.d.ts +16 -0
- package/dist/article-link.js +25 -0
- package/dist/article-link.js.map +1 -0
- package/dist/artifacts.d.ts +45 -0
- package/dist/artifacts.js +144 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/brand.d.ts +4 -0
- package/dist/brand.js +9 -0
- package/dist/brand.js.map +1 -0
- package/dist/bridge.d.ts +2964 -0
- package/dist/bridge.js +1036 -0
- package/dist/bridge.js.map +1 -0
- package/dist/capacity.d.ts +5 -0
- package/dist/capacity.js +11 -0
- package/dist/capacity.js.map +1 -0
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +88 -0
- package/dist/credentials.js.map +1 -0
- package/dist/development-tls.d.ts +8 -0
- package/dist/development-tls.js +38 -0
- package/dist/development-tls.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +11 -0
- package/dist/export.js +91 -0
- package/dist/export.js.map +1 -0
- package/dist/hosted-operations.d.ts +108 -0
- package/dist/hosted-operations.js +120 -0
- package/dist/hosted-operations.js.map +1 -0
- package/dist/hosting-benefits.d.ts +66 -0
- package/dist/hosting-benefits.js +67 -0
- package/dist/hosting-benefits.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/local-inventory.d.ts +22 -0
- package/dist/local-inventory.js +59 -0
- package/dist/local-inventory.js.map +1 -0
- package/dist/local-setup.d.ts +208 -0
- package/dist/local-setup.js +140 -0
- package/dist/local-setup.js.map +1 -0
- package/dist/pairing.d.ts +71 -0
- package/dist/pairing.js +235 -0
- package/dist/pairing.js.map +1 -0
- package/dist/preferences.d.ts +18 -0
- package/dist/preferences.js +44 -0
- package/dist/preferences.js.map +1 -0
- package/dist/progress.d.ts +205 -0
- package/dist/progress.js +224 -0
- package/dist/progress.js.map +1 -0
- package/dist/reminders.d.ts +31 -0
- package/dist/reminders.js +73 -0
- package/dist/reminders.js.map +1 -0
- package/dist/replace-connection.d.ts +6 -0
- package/dist/replace-connection.js +77 -0
- package/dist/replace-connection.js.map +1 -0
- package/dist/repository-actions.d.ts +18 -0
- package/dist/repository-actions.js +9 -0
- package/dist/repository-actions.js.map +1 -0
- package/dist/repository-benefits.d.ts +96 -0
- package/dist/repository-benefits.js +64 -0
- package/dist/repository-benefits.js.map +1 -0
- package/dist/run-update.d.ts +17 -0
- package/dist/run-update.js +27 -0
- package/dist/run-update.js.map +1 -0
- package/dist/runs.d.ts +541 -0
- package/dist/runs.js +146 -0
- package/dist/runs.js.map +1 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +58 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +277 -0
- package/dist/schema.js +66 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +298 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +1714 -0
- package/dist/session.js +619 -0
- package/dist/session.js.map +1 -0
- package/dist/settings.d.ts +9 -0
- package/dist/settings.js +21 -0
- package/dist/settings.js.map +1 -0
- package/dist/sync.d.ts +495 -0
- package/dist/sync.js +191 -0
- package/dist/sync.js.map +1 -0
- package/dist/telemetry-scrub.d.ts +13 -0
- package/dist/telemetry-scrub.js +51 -0
- package/dist/telemetry-scrub.js.map +1 -0
- package/dist/telemetry.d.ts +73 -0
- package/dist/telemetry.js +173 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/walkthroughs.d.ts +224 -0
- package/dist/walkthroughs.js +109 -0
- package/dist/walkthroughs.js.map +1 -0
- package/dist/workspace.d.ts +14 -0
- package/dist/workspace.js +127 -0
- package/dist/workspace.js.map +1 -0
- package/dist/writer-agent.d.ts +21 -0
- package/dist/writer-agent.js +27 -0
- package/dist/writer-agent.js.map +1 -0
- package/dist/writer-entry.d.ts +171 -0
- package/dist/writer-entry.js +233 -0
- package/dist/writer-entry.js.map +1 -0
- package/dist/writing-style.d.ts +7 -0
- package/dist/writing-style.js +52 -0
- package/dist/writing-style.js.map +1 -0
- package/engine/SYNC.json +4 -0
- package/engine/VERSION +1 -0
- package/engine/detect-project/README.md +141 -0
- package/engine/detect-project/SKILL.md +1421 -0
- package/engine/detect-project/assets/desktop/desktop-frame.css +428 -0
- package/engine/detect-project/assets/game/game-frame.css +132 -0
- package/engine/detect-project/assets/macosui/LICENSE-puppertino.txt +21 -0
- package/engine/detect-project/assets/macosui/VERSIONS.txt +1 -0
- package/engine/detect-project/assets/macosui/fonts.css +15 -0
- package/engine/detect-project/assets/macosui/macos-frame.css +481 -0
- package/engine/detect-project/assets/macosui/puppertino.css +2153 -0
- package/engine/detect-project/assets/mobileui/LICENSE-fonts.txt +13 -0
- package/engine/detect-project/assets/mobileui/LICENSE-framework7.txt +52 -0
- package/engine/detect-project/assets/mobileui/VERSIONS.txt +6 -0
- package/engine/detect-project/assets/mobileui/device-frame.css +316 -0
- package/engine/detect-project/assets/mobileui/f7-color-theme.mjs +1345 -0
- package/engine/detect-project/assets/mobileui/f7-icons-names.json +1254 -0
- package/engine/detect-project/assets/mobileui/fonts.css +16 -0
- package/engine/detect-project/assets/mobileui/framework7-components.css +39 -0
- package/engine/detect-project/assets/mobileui/framework7-core.css +5245 -0
- package/engine/detect-project/assets/mobileui/icons.css +31 -0
- package/engine/detect-project/assets/mobileui/md3-defaults.css +89 -0
- package/engine/detect-project/assets/mobileui/platforms.json +46 -0
- package/engine/detect-project/assets/tailwind-fallback.css +1729 -0
- package/engine/detect-project/assets/webtui/LICENSE-webtui.txt +28 -0
- package/engine/detect-project/assets/webtui/VERSIONS.txt +7 -0
- package/engine/detect-project/assets/webtui/terminal-frame.css +195 -0
- package/engine/detect-project/assets/webtui/theme-catppuccin.css +1 -0
- package/engine/detect-project/assets/webtui/theme-everforest.css +1 -0
- package/engine/detect-project/assets/webtui/theme-gruvbox.css +1 -0
- package/engine/detect-project/assets/webtui/theme-nord.css +1 -0
- package/engine/detect-project/assets/webtui/theme-vitesse.css +1 -0
- package/engine/detect-project/assets/webtui/themes.json +37 -0
- package/engine/detect-project/assets/webtui/webtui-core.css +1 -0
- package/engine/detect-project/assets/win32ui/7css.css +2 -0
- package/engine/detect-project/assets/win32ui/LICENSE-7css.txt +21 -0
- package/engine/detect-project/assets/win32ui/VERSIONS.txt +1 -0
- package/engine/detect-project/assets/win32ui/win32-frame.css +278 -0
- package/engine/detect-project/package-lock.json +12 -0
- package/engine/detect-project/package.json +10 -0
- package/engine/detect-project/scripts/apply_runtime_profiles.js +313 -0
- package/engine/detect-project/scripts/check_css_health.js +412 -0
- package/engine/detect-project/scripts/check_project_map.js +150 -0
- package/engine/detect-project/scripts/check_runtime_coverage.js +311 -0
- package/engine/detect-project/scripts/check_runtime_recipe_quality.js +184 -0
- package/engine/detect-project/scripts/classify_app_type.sh +246 -0
- package/engine/detect-project/scripts/classify_surface.sh +95 -0
- package/engine/detect-project/scripts/classify_workspace.js +39 -0
- package/engine/detect-project/scripts/compile_css.sh +447 -0
- package/engine/detect-project/scripts/detect_static.js +645 -0
- package/engine/detect-project/scripts/detect_structure.js +187 -0
- package/engine/detect-project/scripts/include_census.js +451 -0
- package/engine/detect-project/scripts/json_get.js +142 -0
- package/engine/detect-project/scripts/merge_json.js +52 -0
- package/engine/detect-project/scripts/recommend_model_tier.js +252 -0
- package/engine/detect-project/scripts/resolve_route_chains.js +135 -0
- package/engine/detect-project/scripts/run_css_build.sh +40 -0
- package/engine/detect-project/scripts/sanitize_css.js +83 -0
- package/engine/detect-project/scripts/test_classify_surface.js +108 -0
- package/engine/detect-project/scripts/test_node_helpers.js +142 -0
- package/engine/detect-project/scripts/test_recommend_model_tier.js +119 -0
- package/engine/detect-project/scripts/test_resolve_route_chains.js +182 -0
- package/engine/detect-project/scripts/test_runtime_coverage.js +323 -0
- package/engine/detect-project/scripts/test_runtime_recipe_quality.js +187 -0
- package/engine/detect-project/scripts/theme_overrides.js +169 -0
- package/engine/detect-project/scripts/write_branding.js +129 -0
- package/engine/generate-illustrated-article/SKILL.md +461 -0
- package/engine/generate-illustrated-article/contracts/desktop.md +90 -0
- package/engine/generate-illustrated-article/contracts/game.md +38 -0
- package/engine/generate-illustrated-article/contracts/label-evidence.md +38 -0
- package/engine/generate-illustrated-article/contracts/macos.md +79 -0
- package/engine/generate-illustrated-article/contracts/mobile.md +40 -0
- package/engine/generate-illustrated-article/contracts/terminal.md +29 -0
- package/engine/generate-illustrated-article/contracts/win32.md +43 -0
- package/engine/generate-illustrated-article/package-lock.json +366 -0
- package/engine/generate-illustrated-article/package.json +15 -0
- package/engine/generate-illustrated-article/scripts/article_blocks.js +34 -0
- package/engine/generate-illustrated-article/scripts/check_article_json.js +105 -0
- package/engine/generate-illustrated-article/scripts/emit_walkthrough_signals.js +100 -0
- package/engine/generate-illustrated-article/scripts/extract_images.js +187 -0
- package/engine/generate-illustrated-article/scripts/generate_content_images.js +376 -0
- package/engine/generate-illustrated-article/scripts/include_census.js +451 -0
- package/engine/generate-illustrated-article/scripts/inject_assets.js +1691 -0
- package/engine/generate-illustrated-article/scripts/jit_mockup_css.js +191 -0
- package/engine/generate-illustrated-article/scripts/label_evidence.js +87 -0
- package/engine/generate-illustrated-article/scripts/lint_article_copy.js +252 -0
- package/engine/generate-illustrated-article/scripts/lint_mockup_fidelity.js +2403 -0
- package/engine/generate-illustrated-article/scripts/polish_tickets.js +1148 -0
- package/engine/generate-illustrated-article/scripts/related_repos.sh +52 -0
- package/engine/generate-illustrated-article/scripts/render_all.js +177 -0
- package/engine/generate-illustrated-article/scripts/render_mockup.js +665 -0
- package/engine/generate-illustrated-article/scripts/render_ready.js +164 -0
- package/engine/generate-illustrated-article/scripts/resolve_workspace.sh +86 -0
- package/engine/generate-illustrated-article/scripts/runtime_region_geometry.js +55 -0
- package/engine/generate-illustrated-article/scripts/source_paths.js +49 -0
- package/engine/generate-illustrated-article/scripts/test_control_visibility.js +30 -0
- package/engine/generate-illustrated-article/scripts/test_emit_walkthrough_signals.js +157 -0
- package/engine/generate-illustrated-article/scripts/test_generate_content_images.js +168 -0
- package/engine/generate-illustrated-article/scripts/test_include_census.js +126 -0
- package/engine/generate-illustrated-article/scripts/test_label_evidence.js +49 -0
- package/engine/generate-illustrated-article/scripts/test_lint_article_copy.js +138 -0
- package/engine/generate-illustrated-article/scripts/test_lint_mockup_fidelity.js +653 -0
- package/engine/generate-illustrated-article/scripts/test_node_ports.js +152 -0
- package/engine/generate-illustrated-article/scripts/test_polish_tickets.js +481 -0
- package/engine/generate-illustrated-article/scripts/test_related_repos.js +64 -0
- package/engine/generate-illustrated-article/scripts/test_render_ready.js +85 -0
- package/engine/generate-illustrated-article/scripts/test_source_paths.js +68 -0
- package/engine/generate-illustrated-article/scripts/trace_hook.js +79 -0
- package/engine/generate-illustrated-article/scripts/trace_hook.sh +4 -0
- package/engine/generate-illustrated-article/scripts/validate_html.js +114 -0
- package/engine/generate-illustrated-article/scripts/watermark.js +69 -0
- package/install.sh +14 -0
- package/package.json +57 -4
- package/scripts/auth.mjs +33 -0
- package/scripts/auto-update.mjs +7 -0
- package/scripts/build-plugin.mjs +58 -0
- package/scripts/build-release.mjs +94 -0
- package/scripts/check-release.mjs +40 -0
- package/scripts/check-runtime.mjs +10 -0
- package/scripts/cli.mjs +8 -0
- package/scripts/install.mjs +64 -0
- package/scripts/lib/agent-runner.mjs +181 -0
- package/scripts/lib/agent-settings.mjs +215 -0
- package/scripts/lib/article-skills.mjs +82 -0
- package/scripts/lib/auto-update.mjs +50 -0
- package/scripts/lib/brand.mjs +9 -0
- package/scripts/lib/browser.mjs +12 -0
- package/scripts/lib/claude-connection.mjs +33 -0
- package/scripts/lib/claude-permissions.mjs +47 -0
- package/scripts/lib/claude-plugin.mjs +15 -0
- package/scripts/lib/claude-writer.mjs +10 -0
- package/scripts/lib/cli-main.mjs +85 -0
- package/scripts/lib/cli.mjs +888 -0
- package/scripts/lib/codex-config.mjs +51 -0
- package/scripts/lib/codex-integration.mjs +61 -0
- package/scripts/lib/codex-skill.mjs +34 -0
- package/scripts/lib/harness-models.mjs +109 -0
- package/scripts/lib/install.mjs +269 -0
- package/scripts/lib/managed-writer.mjs +38 -0
- package/scripts/lib/planning.mjs +263 -0
- package/scripts/lib/prepare-update.mjs +85 -0
- package/scripts/lib/refresh-writers.mjs +12 -0
- package/scripts/lib/remove.mjs +167 -0
- package/scripts/lib/renderer.mjs +34 -0
- package/scripts/lib/terminal.mjs +252 -0
- package/scripts/lib/uninit.mjs +72 -0
- package/scripts/lib/update.mjs +55 -0
- package/scripts/lib/writer-recovery.mjs +35 -0
- package/scripts/lib/yolo.mjs +362 -0
- package/scripts/plugin-session.mjs +65 -0
- package/scripts/prepare-update.mjs +12 -0
- package/scripts/publish-release.mjs +92 -0
- package/scripts/skills/supportpages/SKILL.md +120 -0
- package/scripts/smoke-release.mjs +101 -0
- package/server.json +27 -0
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
import { HostedOperations } from './hosted-operations.js';
|
|
2
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { resolveAction, requireExecution } from './actions.js';
|
|
10
|
+
import { connectionStateRoot } from './api.js';
|
|
11
|
+
import { preferences, defaultPreferences } from './settings.js';
|
|
12
|
+
import { bindingSchema, localSchema as localSettingsSchema, manifestSchema, contextSchema, importResponseSchema, remoteArticleSchema, remoteId, idSchema, articleType } from './schema.js';
|
|
13
|
+
import { createManifest, parse, preview, snapshot, verifyBundle } from './artifacts.js';
|
|
14
|
+
import { fail, publicError } from './errors.js';
|
|
15
|
+
import { checkArticleCapacity } from './capacity.js';
|
|
16
|
+
import { pluginName, writerAgentName, writerAgentType, claudeWriterAgents, writerLaunchInstruction, codexWriterLaunchInstruction, backgroundParentInstruction, writerReportingInstruction, writerPermissionInstruction } from './writer-agent.js';
|
|
17
|
+
import { writerEntry, writerScreenshotLimit } from './writer-entry.js';
|
|
18
|
+
import { articleChatInstruction, localChatInstruction, articleLink } from './article-link.js';
|
|
19
|
+
import { composeWritingStyle } from './writing-style.js';
|
|
20
|
+
import { exportArticle } from './export.js';
|
|
21
|
+
import { executionSettings } from './agent-settings.js';
|
|
22
|
+
import { LocalSetup } from './local-setup.js';
|
|
23
|
+
import { localArticleInventory } from './local-inventory.js';
|
|
24
|
+
import { ProjectSync } from './sync.js';
|
|
25
|
+
const exec = promisify(execFile);
|
|
26
|
+
import { ProgressRelay, progressResponse } from './progress.js';
|
|
27
|
+
import { Runs, runSchema, processSessionId, uploadRecovery } from './runs.js';
|
|
28
|
+
import { RepositoryReminders, repositoryShowText } from './repository-benefits.js';
|
|
29
|
+
import { HostingReminders, hostingShowText } from './hosting-benefits.js';
|
|
30
|
+
import { activeTelemetry, secondsSince, track } from './telemetry.js';
|
|
31
|
+
import { CLI_NAME } from './brand.js';
|
|
32
|
+
/** What to do about an analysis that is missing or was rejected by validation. */
|
|
33
|
+
export function analysisInstruction(analysis) {
|
|
34
|
+
if (analysis.status === 'ready')
|
|
35
|
+
return undefined;
|
|
36
|
+
const appType = typeof analysis.app_type === 'string' ? analysis.app_type : undefined;
|
|
37
|
+
const reason = analysis.error?.message ? `${analysis.error.message} ` : '';
|
|
38
|
+
if (appType) {
|
|
39
|
+
return `${reason}Project detection needs the app type it asked for. In the project terminal run ${CLI_NAME} analyse --app-type ${appType}, or rerun the detect-project skill with app_type=${appType}.`;
|
|
40
|
+
}
|
|
41
|
+
return analysis.status === 'invalid'
|
|
42
|
+
? `${reason}Run ${CLI_NAME} analyse in the project terminal to rebuild the project analysis.`
|
|
43
|
+
: `Run ${CLI_NAME} analyse in the project terminal before creating an article.`;
|
|
44
|
+
}
|
|
45
|
+
/** Counts a locally written article's end state: saved in the folder, or uploaded to a help centre. */
|
|
46
|
+
function trackArticle(run, outcome, errorCode) {
|
|
47
|
+
track('article_completed', { location: run.project_id ? 'upload' : 'local', outcome, error_code: errorCode, duration_s: secondsSince(run.first_writer_started_at) });
|
|
48
|
+
}
|
|
49
|
+
const localSchema = z.object({ project_id: remoteId.optional(), api_origin: z.url().optional(), local_article_id: z.uuid(), artifact_dir: z.string(), bundle_dir: z.string(), bundle_hash: z.string(), uploaded_bundle_hash: z.string().optional(), remote: remoteArticleSchema.optional() });
|
|
50
|
+
export class Bridge {
|
|
51
|
+
ws;
|
|
52
|
+
api;
|
|
53
|
+
skillsDir;
|
|
54
|
+
sessionId;
|
|
55
|
+
source;
|
|
56
|
+
configDir;
|
|
57
|
+
relay = new ProgressRelay(this);
|
|
58
|
+
operations = Promise.resolve();
|
|
59
|
+
lock(operation) {
|
|
60
|
+
const result = this.operations.then(() => this.ws.lock(operation));
|
|
61
|
+
this.operations = result.catch(() => { });
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
close() { this.relay.stop(); return this.operations; }
|
|
65
|
+
get stateRoot() { return connectionStateRoot(this.api.origin, this.api.dev); }
|
|
66
|
+
constructor(ws, api, skillsDir, sessionId = processSessionId, source, configDir) {
|
|
67
|
+
this.ws = ws;
|
|
68
|
+
this.api = api;
|
|
69
|
+
this.skillsDir = skillsDir;
|
|
70
|
+
this.sessionId = sessionId;
|
|
71
|
+
this.source = source;
|
|
72
|
+
this.configDir = configDir;
|
|
73
|
+
}
|
|
74
|
+
async repositoryReminders(enabled) {
|
|
75
|
+
const binding = await this.binding();
|
|
76
|
+
return new RepositoryReminders(this.configDir).preference(this.api.origin, binding.project_id, enabled);
|
|
77
|
+
}
|
|
78
|
+
/** Needs no help centre or sign-in: the preference belongs to this device's local folders. */
|
|
79
|
+
hostingReminders(enabled) {
|
|
80
|
+
return new HostingReminders(this.configDir).preference(this.api.origin, enabled);
|
|
81
|
+
}
|
|
82
|
+
get runs() { return new Runs(this.ws, this.stateRoot, this.sessionId); }
|
|
83
|
+
async resumeWriterCompletion() {
|
|
84
|
+
const file = `${this.stateRoot}/active-run.json`;
|
|
85
|
+
if (!await this.ws.exists(file))
|
|
86
|
+
return;
|
|
87
|
+
const active = parse(runSchema, await this.ws.json(file));
|
|
88
|
+
const run = await this.runs.read(active.id);
|
|
89
|
+
if (run.writer_completed_at && !run.remote && !run.error && run.phase !== 'saved' && ['prepared', 'finalized'].includes(run.status))
|
|
90
|
+
this.relay.start(run.id);
|
|
91
|
+
}
|
|
92
|
+
get setup() { return new LocalSetup(this.ws, this.stateRoot); }
|
|
93
|
+
sync() { return this.lock(() => new ProjectSync(this).refresh()); }
|
|
94
|
+
async localPlan() {
|
|
95
|
+
return { analysis: await this.setup.analysis() };
|
|
96
|
+
}
|
|
97
|
+
/** Settings of a workspace that saves articles locally; undefined when never set up that way. */
|
|
98
|
+
async local() {
|
|
99
|
+
if (!await this.ws.exists(`${this.stateRoot}/local.json`))
|
|
100
|
+
return undefined;
|
|
101
|
+
return parse(localSettingsSchema, await this.ws.json(`${this.stateRoot}/local.json`), 'invalid_configuration');
|
|
102
|
+
}
|
|
103
|
+
async saveLocal(settings) {
|
|
104
|
+
await this.ws.resolve(settings.export_dir);
|
|
105
|
+
const fresh = !await this.ws.exists(`${this.stateRoot}/local.json`);
|
|
106
|
+
await this.ws.writeJson(`${this.stateRoot}/local.json`, parse(localSettingsSchema, settings, 'invalid_configuration'));
|
|
107
|
+
if (fresh)
|
|
108
|
+
track('project_init', { location: 'local' });
|
|
109
|
+
return settings;
|
|
110
|
+
}
|
|
111
|
+
/** A saved help-centre link always wins over local settings left behind by publish. */
|
|
112
|
+
async destination() {
|
|
113
|
+
if (await this.ws.exists(`${this.stateRoot}/binding.json`))
|
|
114
|
+
return 'hosted';
|
|
115
|
+
return await this.ws.exists(`${this.stateRoot}/local.json`) ? 'local' : 'none';
|
|
116
|
+
}
|
|
117
|
+
/** Context for a local workspace: no sections, inventory or capacity; writing style composed here. */
|
|
118
|
+
async localContext() {
|
|
119
|
+
const local = await this.local();
|
|
120
|
+
if (!local)
|
|
121
|
+
fail('local_required', `This folder is not set up to save articles locally. Run ${CLI_NAME} init.`);
|
|
122
|
+
return { local: true, project: { name: path.basename(this.ws.root) }, supported_bundle_versions: [1], sections: [], articles: [],
|
|
123
|
+
product_context: {}, inventory_truncated: false, writing_style: composeWritingStyle(local.writing_style) };
|
|
124
|
+
}
|
|
125
|
+
async contextFor(destination) {
|
|
126
|
+
return destination === 'local' ? this.localContext() : this.context();
|
|
127
|
+
}
|
|
128
|
+
async binding() {
|
|
129
|
+
if (!await this.ws.exists(`${this.stateRoot}/binding.json`) && await this.ws.exists(`${this.stateRoot}/local.json`)) {
|
|
130
|
+
fail('local_workspace', `This folder saves articles locally and has no help centre. Run ${CLI_NAME} publish in the terminal to sign in and host them.`);
|
|
131
|
+
}
|
|
132
|
+
const binding = parse(bindingSchema, await this.ws.json(`${this.stateRoot}/binding.json`));
|
|
133
|
+
if (binding.api_origin !== this.api.origin)
|
|
134
|
+
fail('destination_mismatch', 'This workspace is bound to a different API origin. Rebind it deliberately before continuing.');
|
|
135
|
+
return binding;
|
|
136
|
+
}
|
|
137
|
+
/** Plugin installs download Chromium in the background when a session starts
|
|
138
|
+
* (scripts/plugin-session.mjs). Say so, rather than failing mid-render. */
|
|
139
|
+
async requirePluginRenderer() {
|
|
140
|
+
const scripts = new URL('../scripts/', import.meta.url);
|
|
141
|
+
const { rendererReady } = await import(new URL('lib/renderer.mjs', scripts).href);
|
|
142
|
+
const { command } = await import(new URL('lib/install.mjs', scripts).href);
|
|
143
|
+
const quiet = (cmd, args, options = {}) => command(cmd, args, { ...options, capture: true });
|
|
144
|
+
if (await rendererReady(this.skillsDir, quiet))
|
|
145
|
+
return;
|
|
146
|
+
const session = await import(new URL('plugin-session.mjs', scripts).href);
|
|
147
|
+
await session.startDownload();
|
|
148
|
+
fail('renderer_downloading', 'Chromium for article screenshots is still downloading; the first download takes a few minutes. Wait a minute, then prepare the article again.');
|
|
149
|
+
}
|
|
150
|
+
async version() { try {
|
|
151
|
+
return (await readFile(path.join(this.skillsDir, 'VERSION'), 'utf8')).trim();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return 'unknown';
|
|
155
|
+
} }
|
|
156
|
+
async doctor() {
|
|
157
|
+
const dependencies = await Promise.all(['node', 'git'].map(async (command) => {
|
|
158
|
+
try {
|
|
159
|
+
const result = await exec(command === 'node' ? process.execPath : command, ['--version'], { timeout: 5000 });
|
|
160
|
+
return { command, available: true, version: result.stdout.trim() };
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return { command, available: false };
|
|
164
|
+
}
|
|
165
|
+
}));
|
|
166
|
+
const skill = path.join(this.skillsDir, 'generate-illustrated-article', 'SKILL.md');
|
|
167
|
+
let installed = false;
|
|
168
|
+
try {
|
|
169
|
+
installed = (await stat(skill)).isFile();
|
|
170
|
+
}
|
|
171
|
+
catch { /* reported below */ }
|
|
172
|
+
let telemetry = { enabled: false, reason: 'not_configured' };
|
|
173
|
+
try {
|
|
174
|
+
telemetry = await activeTelemetry()?.status() ?? telemetry;
|
|
175
|
+
}
|
|
176
|
+
catch { /* Doctor still reports the rest. */ }
|
|
177
|
+
return { workspace: this.ws.root, api_origin: this.api.origin, dev_mode: this.api.dev, state_directory: this.stateRoot, credentials_configured: this.api.configured(), skills_path: this.skillsDir, skills_version: await this.version(), skills_installed: installed, dependencies, telemetry,
|
|
178
|
+
notes: ['Generation runs in the host agent. This server does not launch a model.', `Article rendering also needs Chromium, which ${CLI_NAME} setup downloads on first use, and any project-specific CSS dependencies.`] };
|
|
179
|
+
}
|
|
180
|
+
listProjects() { return this.api.request('GET', '/projects'); }
|
|
181
|
+
async status(runId) {
|
|
182
|
+
const article_run = await this.runs.progress(runId);
|
|
183
|
+
const analysis = await this.setup.analysis();
|
|
184
|
+
const destination = await this.destination();
|
|
185
|
+
const base = { workspace: this.ws.root, api_origin: this.api.origin, dev_mode: this.api.dev,
|
|
186
|
+
credentials_configured: this.api.configured(), article_run, analysis, setup_task: await this.setup.progress(), generation_ready: false };
|
|
187
|
+
if (destination === 'local') {
|
|
188
|
+
const local = (await this.local());
|
|
189
|
+
return { ...base, status: 'local', export_dir: local.export_dir, writing_style: local.writing_style, generation_ready: analysis.status === 'ready',
|
|
190
|
+
instructions: [analysisInstruction(analysis),
|
|
191
|
+
'This folder saves articles locally as Markdown and screenshots; no help centre is linked and no sign-in is needed, so do not start sign-in on your own. If the user asks to host the saved articles or to use a help centre, ask whether to sign in or create a free SupportPages.io account, then call supportpages_init with host_local=true (signup=true for a new account); use supportpages_publish instead when the saved articles should be uploaded as well.'].filter(Boolean).join(' ') };
|
|
192
|
+
}
|
|
193
|
+
// A folder that has never been set up has not chosen where its articles go.
|
|
194
|
+
// Saving them here needs no account, so this is a setup step, not a sign-in
|
|
195
|
+
// problem; only a folder already linked to a help centre needs a credential.
|
|
196
|
+
if (destination === 'none' && !this.api.configured())
|
|
197
|
+
return { ...base, status: 'setup_required',
|
|
198
|
+
instructions: 'This folder is not set up yet and no account is needed to set it up. It can save articles in the project as Markdown and screenshots with no sign-in, or publish them to a SupportPages.io help centre for a public URL, editor review and AI answers. Do not start sign-in on your own and do not present hosting as required: ask which the user wants, then call supportpages_init (signup=true only when they chose to create a new account).' };
|
|
199
|
+
if (!this.api.configured())
|
|
200
|
+
return { ...base, status: 'authentication_required', instructions: 'This device is not signed in. Do not start sign-in on your own: if the user wants to host articles or read the hosted help centre, ask whether to sign in or create a free SupportPages.io account, then call supportpages_init (signup=true for a new account) to open browser approval.' };
|
|
201
|
+
try {
|
|
202
|
+
const response = z.object({ projects: z.array(z.object({ id: remoteId, name: z.string().max(500), help_centre_url: z.url().optional(), repository_connection: contextSchema.shape.repository_connection })) }).safeParse(await this.listProjects());
|
|
203
|
+
if (!response.success)
|
|
204
|
+
fail('invalid_response', 'The API returned an invalid project list.');
|
|
205
|
+
if (!await this.ws.exists(`${this.stateRoot}/binding.json`))
|
|
206
|
+
return { ...base, status: 'project_required',
|
|
207
|
+
projects: response.data.projects, instructions: 'Signed in, but this folder has not chosen where its articles go. Hosting is not required: it can also save them in the project as Markdown and screenshots. Ask which the user wants, then call supportpages_init with that project_id for a help centre (or supportpages_create_project first), or supportpages_init alone to set the folder up to save locally.' };
|
|
208
|
+
const binding = await this.binding();
|
|
209
|
+
const project = response.data.projects.find(project => project.id === binding.project_id);
|
|
210
|
+
if (!project)
|
|
211
|
+
return { ...base, status: 'project_unavailable', project_id: binding.project_id,
|
|
212
|
+
instructions: 'The signed-in account cannot access the help centre this folder is linked to. Restore access, or call supportpages_init with another project_id to link a different help centre.' };
|
|
213
|
+
const action = project.repository_connection?.writer?.actions.create_article;
|
|
214
|
+
const hosted_operation = await this.hosted.get(undefined, 0, false);
|
|
215
|
+
const next = action ? action.next_step?.message ?? (action.execution === 'local' ? analysisInstruction(analysis) : undefined) : analysisInstruction(analysis);
|
|
216
|
+
return { ...base, status: 'ready', project_id: project.id, project, repository_connection: project.repository_connection,
|
|
217
|
+
hosted_operation, generation_ready: action ? action.allowed && (action.execution !== 'local' || analysis.status === 'ready') : analysis.status === 'ready', writer_action: action, ...(next ? { instructions: next } : {}) };
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
const safe = publicError(error);
|
|
221
|
+
return { ...base, status: safe.code === 'invalid_credentials' ? 'authentication_required' : 'connection_error',
|
|
222
|
+
error: safe, instructions: safe.code === 'invalid_credentials'
|
|
223
|
+
? 'The device sign-in is expired or revoked. Ask before calling supportpages_init to sign in again.'
|
|
224
|
+
: 'Check the reported connection error and retry supportpages_status.' };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
createProject(name, subdomain) { return this.api.request('POST', '/projects', { name, subdomain }); }
|
|
228
|
+
async bind(projectId, options = {}) {
|
|
229
|
+
parse(remoteId, projectId);
|
|
230
|
+
const context = parse(contextSchema, await this.api.request('GET', `/projects/${projectId}/context`), 'invalid_response');
|
|
231
|
+
if (context.project.id !== projectId)
|
|
232
|
+
fail('invalid_response', 'Project identity mismatch.');
|
|
233
|
+
return this.lock(async () => {
|
|
234
|
+
if (options.requireIdle)
|
|
235
|
+
await this.runs.assertAvailable();
|
|
236
|
+
if (await this.ws.exists(`${this.stateRoot}/binding.json`)) {
|
|
237
|
+
const old = parse(bindingSchema, await this.ws.json(`${this.stateRoot}/binding.json`));
|
|
238
|
+
if (old.project_id !== projectId || old.api_origin !== this.api.origin)
|
|
239
|
+
fail('destination_mismatch', 'This workspace is already bound to another destination. Use a separate workspace or archive .rtfm/supportpages before rebinding.');
|
|
240
|
+
}
|
|
241
|
+
const binding = { version: 1, project_id: projectId, api_origin: this.api.origin };
|
|
242
|
+
const fresh = !await this.ws.exists(`${this.stateRoot}/binding.json`);
|
|
243
|
+
await this.ws.writeJson(`${this.stateRoot}/binding.json`, binding);
|
|
244
|
+
if (fresh)
|
|
245
|
+
track('project_init', { location: 'hosted' });
|
|
246
|
+
return { ...binding, project: context.project };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async context() {
|
|
250
|
+
const binding = await this.binding();
|
|
251
|
+
return parse(contextSchema, await this.api.request('GET', `/projects/${binding.project_id}/context`), 'invalid_response');
|
|
252
|
+
}
|
|
253
|
+
/** Recover a confirmed link from local journals without requiring a working API. */
|
|
254
|
+
async articleErrorContext(input) {
|
|
255
|
+
const binding = await this.ws.exists(`${this.stateRoot}/binding.json`) ? await this.binding() : undefined;
|
|
256
|
+
const belongs = (run) => run.project_id === binding?.project_id && run.api_origin === binding?.api_origin;
|
|
257
|
+
let run;
|
|
258
|
+
if (z.uuid().safeParse(input.run_id).success)
|
|
259
|
+
run = await this.runs.read(input.run_id);
|
|
260
|
+
else {
|
|
261
|
+
const artifact = typeof input.artifact_dir === 'string' ? input.artifact_dir : typeof input.title === 'string'
|
|
262
|
+
? `output/articles/${input.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}` : undefined;
|
|
263
|
+
if (!artifact)
|
|
264
|
+
return;
|
|
265
|
+
for (const entry of await this.ws.list(`${this.stateRoot}/runs`)) {
|
|
266
|
+
if (!entry.isDirectory() || !z.uuid().safeParse(entry.name).success)
|
|
267
|
+
continue;
|
|
268
|
+
let candidate;
|
|
269
|
+
try {
|
|
270
|
+
candidate = await this.runs.read(entry.name);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (belongs(candidate) && (candidate.artifact_dir === artifact || typeof input.title === 'string' && candidate.title === input.title) && (!run || candidate.started_at > run.started_at))
|
|
276
|
+
run = candidate;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (!run || !belongs(run))
|
|
280
|
+
return;
|
|
281
|
+
return { run_id: run.id, title: run.title, phase: run.phase, ...articleLink(run),
|
|
282
|
+
recovery: `Inspect this same run with supportpages_status (run_id=${run.id}). If its writer stopped before finishing, use supportpages_retry_article; if it finished locally, retry supportpages_complete_article. Respect run_closed or revision_conflict and review the article in the browser instead of restarting it. Do not prepare a duplicate article.` };
|
|
283
|
+
}
|
|
284
|
+
async prepare(input) {
|
|
285
|
+
requireExecution(await resolveAction(this, 'create_article'), 'local');
|
|
286
|
+
const destination = await this.destination();
|
|
287
|
+
const context = await this.contextFor(destination);
|
|
288
|
+
const local = context.local ? (await this.local()) : undefined;
|
|
289
|
+
if (!context.local)
|
|
290
|
+
checkArticleCapacity(context.article_capacity);
|
|
291
|
+
if (input.section_id && context.local)
|
|
292
|
+
fail('invalid_section', 'This folder saves articles locally and has no sections.');
|
|
293
|
+
if (input.section_id && context.repository_connection?.capabilities.sections === false) {
|
|
294
|
+
fail('repository_connection_required', `Connect a repository before assigning sections: ${context.repository_connection.connect_url}`);
|
|
295
|
+
}
|
|
296
|
+
const savedPreferences = local ? { ...defaultPreferences, ...local.preferences } : await preferences(this.api);
|
|
297
|
+
const writerModel = (await executionSettings(this, 'claude')).model;
|
|
298
|
+
const codexWriter = await executionSettings(this, 'codex');
|
|
299
|
+
if (!context.supported_bundle_versions.includes(1))
|
|
300
|
+
fail('unsupported_schema', 'The remote server does not support bundle version 1.');
|
|
301
|
+
if (input.section_id && !context.sections.some(s => s.id === input.section_id))
|
|
302
|
+
fail('invalid_section', 'Select a section belonging to the bound project.');
|
|
303
|
+
const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
304
|
+
if (!slug || slug.length > 120)
|
|
305
|
+
fail('invalid_title', 'Use a title that produces a nonempty ASCII slug of at most 120 characters.');
|
|
306
|
+
try {
|
|
307
|
+
await stat(path.join(this.skillsDir, 'generate-illustrated-article', 'SKILL.md'));
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
fail('missing_dependency', 'The article engine is missing from this installation. Reinstall SupportPages Writer, or point RTFM_SKILLS_DIR at an engine directory.');
|
|
311
|
+
}
|
|
312
|
+
if (pluginName)
|
|
313
|
+
await this.requirePluginRenderer();
|
|
314
|
+
return this.lock(async () => {
|
|
315
|
+
if (!context.local) {
|
|
316
|
+
const synced = await new ProjectSync(this).refresh(context, false);
|
|
317
|
+
if (synced.status === 'ready' && !input.allow_duplicate) {
|
|
318
|
+
const duplicate = synced.articles.find(item => item.remote?.deleted_at === null && item.remote.title.trim().toLowerCase() === input.title.trim().toLowerCase());
|
|
319
|
+
if (duplicate?.remote && 'editor_url' in duplicate.remote)
|
|
320
|
+
fail('article_exists', 'An article with this title already exists in this help centre. Review it in the editor. Create another only if the user explicitly wants a separate article.', { editor_url: duplicate.remote.editor_url, article_id: duplicate.remote.id });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
await this.runs.assertAvailable();
|
|
324
|
+
if (context.local ? await this.destination() !== 'local' : (await this.binding()).project_id !== context.project.id)
|
|
325
|
+
fail('destination_mismatch', 'The connected help centre changed while preparing this article. Start again with the current connection.');
|
|
326
|
+
const analysis = await this.setup.requireAnalysis();
|
|
327
|
+
const scope = createHash('sha256').update(`${this.api.dev}\n${this.api.origin}`).digest('hex').slice(0, 16);
|
|
328
|
+
const articleSlug = input.allow_duplicate ? `${slug.slice(0, 100)}-${randomUUID().slice(0, 8)}` : slug;
|
|
329
|
+
const artifactDir = `${this.stateRoot}/work/${context.local ? 'local' : context.project.id}/${articleSlug}`;
|
|
330
|
+
// Keep earlier outputs retryable in place, and do not start a second writer
|
|
331
|
+
// for a topic just because new runs now use the private working directory.
|
|
332
|
+
const previousDirs = local ? [`output/articles/${articleSlug}`, path.posix.join(local.export_dir, articleSlug)]
|
|
333
|
+
: [`output/articles/${scope}-${context.project.id}/${articleSlug}`, `output/articles/${articleSlug}`];
|
|
334
|
+
for (const directory of [artifactDir, ...previousDirs]) {
|
|
335
|
+
if (await this.ws.exists(directory))
|
|
336
|
+
fail('output_exists', 'Work for this topic already exists. Check the existing article status and continue that article instead of starting a duplicate.');
|
|
337
|
+
}
|
|
338
|
+
const run = { writer_token: randomUUID(), codebase_dir: analysis.codebase_dir, id: randomUUID(), local_article_id: randomUUID(), artifact_dir: artifactDir, started_at: new Date().toISOString(), section_id: input.section_id ?? null, article_type: input.article_type, ...(context.progressive_articles ? { progress: { attempt: randomUUID(), sequence: 0 } } : {}), status: 'prepared', skills_version: await this.version(), title: input.title, ...(local ? { local_export: { directory: local.export_dir } } : { api_origin: this.api.origin, project_id: context.project.id }), prefer_background: input.prefer_background ?? savedPreferences.prefer_background, open_when_ready: input.open_when_ready ?? savedPreferences.open_when_ready, phase: 'prepared', session_id: this.sessionId };
|
|
339
|
+
const writerWorkspace = await this.ws.resolve(analysis.codebase_dir);
|
|
340
|
+
const outputPath = await this.ws.resolve(artifactDir);
|
|
341
|
+
const writerArtifactDir = analysis.codebase_dir === '.' ? artifactDir : outputPath;
|
|
342
|
+
const contextFile = await this.ws.writeJson(`${this.stateRoot}/runs/${run.id}/context.json`, { ...context, project_overview: analysis.overview, analysis_summary: analysis.summary, project_name: context.project.name, article_title: input.title, article_description: input.description ?? '', article_type: input.article_type });
|
|
343
|
+
await this.ws.writeJson(`${this.stateRoot}/runs/${run.id}/run.json`, run);
|
|
344
|
+
await this.ws.writeJson(`${this.stateRoot}/active-run.json`, run);
|
|
345
|
+
// Reserve a preview before handing off to the host, including foreground writers.
|
|
346
|
+
// A lost response is recoverable through this same idempotent run ID.
|
|
347
|
+
if (run.progress) {
|
|
348
|
+
await this.relay.ensureStartedUnlocked(run.id);
|
|
349
|
+
}
|
|
350
|
+
this.relay.start(run.id);
|
|
351
|
+
const link = articleLink(await this.runs.read(run.id));
|
|
352
|
+
const entrypoint = writerEntry(this.ws.root, this.stateRoot, run.id, this.skillsDir, run.writer_token);
|
|
353
|
+
const environment = { RTFM_WORKSPACE: writerWorkspace, RTFM_CONTEXT_FILE: contextFile, RTFM_OUTPUT_DIR: outputPath, RTFM_SKILLS_DIR: this.skillsDir, RTFM_MAX_IMAGES: String(writerScreenshotLimit), PATH: path.dirname(process.execPath) + path.delimiter + (process.env.PATH ?? '') };
|
|
354
|
+
const savedTo = local ? path.posix.join(local.export_dir, articleSlug) : undefined;
|
|
355
|
+
return { status: 'prepared', run_id: run.id, artifact_dir: artifactDir, environment, ...link,
|
|
356
|
+
...(local ? { local: true, saved_to: savedTo, link_message: `The article will be saved to ${savedTo}.`, link_instructions: localChatInstruction } : {}),
|
|
357
|
+
prefer_background: run.prefer_background, open_when_ready: run.open_when_ready,
|
|
358
|
+
task_brief: { name: writerAgentName, claude_model: writerModel, codex_model: codexWriter.model, codex_reasoning_effort: codexWriter.effort,
|
|
359
|
+
prefer_background: run.prefer_background, reporting_instructions: writerReportingInstruction(run.prefer_background === true),
|
|
360
|
+
writer_instructions: claudeWriterAgents[writerAgentType].prompt, workspace: writerWorkspace, run_id: run.id, artifact_dir: writerArtifactDir,
|
|
361
|
+
environment, entrypoint, execution: entrypoint.execution, completion: entrypoint.completion, editor_url: link.editor_url,
|
|
362
|
+
instructions: [entrypoint.instruction, entrypoint.execution.instruction,
|
|
363
|
+
`The application and its .rtfm cache are in ${writerWorkspace}. Use ${outputPath} as OUT for every skill command instead of the default ./output/articles directory. Keep all article artifacts there so the MCP can observe and upload them.`,
|
|
364
|
+
`Read ${path.join(this.skillsDir, 'detect-project', 'SKILL.md')} and run detect-project if the project map is missing, stale, or reports an incomplete detection or a wrong app_type. An incomplete map names the app type it needs (app_type=…): rerun detection with that app type before generating, and report the block if it cannot be resolved.`,
|
|
365
|
+
'Generate the requested article using the instructions returned by the entry check and the supplied context and environment.',
|
|
366
|
+
'Finish all images, final rendering and fidelity validation. Do not edit application source code.',
|
|
367
|
+
entrypoint.completion.instruction,
|
|
368
|
+
writerPermissionInstruction,
|
|
369
|
+
'Report success only after the full pipeline finishes. Report unresolved failures to the main agent. Do not call MCP tools, upload or publish.'] },
|
|
370
|
+
instructions: [
|
|
371
|
+
local ? localChatInstruction : link.link_instructions,
|
|
372
|
+
writerLaunchInstruction,
|
|
373
|
+
codexWriterLaunchInstruction,
|
|
374
|
+
backgroundParentInstruction,
|
|
375
|
+
'Keep this run ID and task brief in the main conversation. Use the current host agent, not a separate agent CLI or paid runner.',
|
|
376
|
+
'Use the existing application directory in task_brief.workspace. Do not request worktree isolation or create a new checkout for this writer: it needs the existing .rtfm cache and tracked output directory. Background execution does not require a worktree. If the host cannot launch without one, use the foreground with this same prepared run.',
|
|
377
|
+
run.prefer_background ? 'Launch the task brief with the host’s native background task facility. If unavailable or permission-blocked, explain briefly and execute it in the foreground. Stop any failed background writer before foreground retry.' : 'Run the task brief in the foreground.',
|
|
378
|
+
'Only after launch succeeds, call supportpages_update_run with event=started, execution_mode and the host_task_id when available. A prepared run is not yet writing. The writer entrypoint independently records actual startup and enables live previews, so a missed host update does not interrupt delivery.',
|
|
379
|
+
'Preserve and inspect the host task completion result. Stay available for approvals and recovery until the writer finishes or needs user intervention. If delegation cannot support approvals, stop the writer and use foreground recovery with the same permissions. Do not leave a stopped run falsely marked writing.',
|
|
380
|
+
...(local ? [
|
|
381
|
+
'On successful host completion, call supportpages_complete_article with this run_id and completed=true, even if the writer finish command already reported the article as saved. It returns the saved Markdown path; show that path as the deliverable. If the finish command reports delivery_pending, retry complete_article without regenerating or marking the writer failed. On writing failure or interruption, report it with supportpages_update_run and stopped=true only after confirming the writer stopped.',
|
|
382
|
+
'This folder has no help centre: never ask whether to publish automatically. Only if the user explicitly asks to host or publish saved articles, call supportpages_publish; otherwise never start browser sign-in or call upload or publish tools. Any hosting offer comes from complete_article as hosting_invitation; do not add your own.',
|
|
383
|
+
'Never finalize a failed, interrupted, or partial run. No preview link exists for local articles.',
|
|
384
|
+
] : [
|
|
385
|
+
'On successful host completion, call supportpages_complete_article with this run_id and completed=true to obtain the delivery instructions, even if the writer finish command already delivered the draft. Do not claim the article is ready or ask to publish until delivery is confirmed. If the finish command reports delivery_pending or upload_failed, retry complete_article without regenerating or marking the writer failed. On writing failure or interruption, report it with supportpages_update_run and stopped=true only after confirming the writer stopped.',
|
|
386
|
+
'Return the completed editor link and ask: Would you like me to publish it? No answer or a refusal leaves a draft. Opening the browser is not publication consent.',
|
|
387
|
+
'Never finalize a failed, interrupted, or partial run. The MCP relay uploads read-only preview milestones automatically; show the preview link returned by update_run. It handles early browser opening when enabled.',
|
|
388
|
+
])
|
|
389
|
+
],
|
|
390
|
+
writer_entrypoint: entrypoint };
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
async updateRun(input) {
|
|
394
|
+
return this.lock(async () => {
|
|
395
|
+
const run = await this.runs.read(input.run_id);
|
|
396
|
+
if (run.status !== 'prepared')
|
|
397
|
+
fail('invalid_run', 'Only a prepared or writing run can receive host execution updates.');
|
|
398
|
+
if (input.event === 'started') {
|
|
399
|
+
if (!input.execution_mode)
|
|
400
|
+
fail('invalid_run', 'Report the actual execution mode after successfully starting the writer.');
|
|
401
|
+
if (run.phase === 'writing' && run.execution_mode !== undefined && (run.execution_mode !== input.execution_mode || (input.host_task_id && input.host_task_id !== run.host_task_id)) && !input.stopped)
|
|
402
|
+
fail('writer_not_stopped', 'Confirm stopped=true before replacing the current writer.');
|
|
403
|
+
await this.runs.assertAvailable(run.id);
|
|
404
|
+
await this.runs.save({ ...run, phase: 'writing', session_id: this.sessionId, first_writer_started_at: run.first_writer_started_at ?? new Date().toISOString(),
|
|
405
|
+
execution_mode: input.execution_mode, host_task_id: input.host_task_id ?? (input.execution_mode === run.execution_mode ? run.host_task_id : undefined), error: undefined });
|
|
406
|
+
if (run.progress) {
|
|
407
|
+
try {
|
|
408
|
+
await this.relay.ensureStartedUnlocked(run.id);
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
await this.runs.save({ ...(await this.runs.read(run.id)), progress: { ...run.progress, sync_error: publicError(error).message } });
|
|
412
|
+
}
|
|
413
|
+
this.relay.start(run.id);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
if (!input.stopped)
|
|
418
|
+
fail('writer_not_stopped', 'Stop the host task and confirm stopped=true before releasing this run.');
|
|
419
|
+
this.relay.stop();
|
|
420
|
+
await this.reportProgressEvent(run, input.event);
|
|
421
|
+
await this.runs.save({ ...(await this.runs.read(run.id)), status: input.event, phase: input.event });
|
|
422
|
+
trackArticle(run, input.event);
|
|
423
|
+
}
|
|
424
|
+
return this.runs.progress(run.id);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
async cancel(runId, stopped = false, expectedRun) {
|
|
428
|
+
return this.lock(async () => {
|
|
429
|
+
const run = await this.runs.read(runId);
|
|
430
|
+
if (expectedRun && JSON.stringify(run) !== JSON.stringify(expectedRun))
|
|
431
|
+
fail('run_changed', 'The article run changed while you were reviewing it. Check its latest progress before clearing it.');
|
|
432
|
+
if (run.status !== 'prepared')
|
|
433
|
+
fail('invalid_run', 'Only a prepared or writing run can be cancelled.');
|
|
434
|
+
if (run.phase === 'writing' && !stopped)
|
|
435
|
+
fail('writer_not_stopped', 'Stop the host writer, then call cancel_run with stopped=true.');
|
|
436
|
+
this.relay.stop();
|
|
437
|
+
await this.reportProgressEvent(run, 'cancelled');
|
|
438
|
+
await this.runs.save({ ...(await this.runs.read(run.id)), status: 'cancelled', phase: 'cancelled' });
|
|
439
|
+
trackArticle(run, 'cancelled');
|
|
440
|
+
return { status: 'cancelled', ...articleLink(run), note: 'Run bookkeeping released. The server does not stop the host agent or delete its output.' };
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
async validate(dir) {
|
|
444
|
+
const snap = await snapshot(this.ws, dir);
|
|
445
|
+
return { valid: true, title: snap.article.title, blocks: snap.article.blocks.length, images: snap.images.length, warnings: snap.warnings, finalized: false };
|
|
446
|
+
}
|
|
447
|
+
async preview(dir) {
|
|
448
|
+
const snap = await snapshot(this.ws, dir);
|
|
449
|
+
const file = await this.ws.write(`${this.stateRoot}/previews/${randomUUID()}.html`, preview(snap));
|
|
450
|
+
return { path: file, uri: pathToFileURL(file).href, title: snap.article.title, warnings: snap.warnings };
|
|
451
|
+
}
|
|
452
|
+
async finalize(input) {
|
|
453
|
+
if (!input.completed)
|
|
454
|
+
fail('incomplete_generation', 'Finalize only after the entire generation pipeline succeeds.');
|
|
455
|
+
return this.lock(() => this.finalizeUnlocked(input));
|
|
456
|
+
}
|
|
457
|
+
async finalizeUnlocked(input) {
|
|
458
|
+
await this.ws.resolve(input.artifact_dir);
|
|
459
|
+
const legacy = /^output\/articles\/(?:([a-f0-9]{16}-[1-9][0-9]*)\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/.exec(input.artifact_dir);
|
|
460
|
+
const working = /^(\.rtfm\/supportpages(?:\/dev\/[a-f0-9]{16})?)\/work\/(local|[1-9][0-9]*)\/[a-z0-9]+(?:-[a-z0-9]+)*$/.exec(input.artifact_dir);
|
|
461
|
+
if (!legacy && !working)
|
|
462
|
+
fail('invalid_path', 'Finalize an article output directory returned by SupportPages.io.');
|
|
463
|
+
const scope = createHash('sha256').update(`${this.api.dev}\n${this.api.origin}`).digest('hex').slice(0, 16);
|
|
464
|
+
if ((legacy?.[1] && legacy[1] !== `${scope}-${(await this.binding()).project_id}`) ||
|
|
465
|
+
(working && (working[1] !== this.stateRoot || (working[2] === 'local'
|
|
466
|
+
? await this.destination() !== 'local' : working[2] !== (await this.binding()).project_id)))) {
|
|
467
|
+
fail('destination_mismatch', 'These article outputs belong to a different help centre.');
|
|
468
|
+
}
|
|
469
|
+
let run;
|
|
470
|
+
if (await this.ws.exists(`${this.stateRoot}/active-run.json`)) {
|
|
471
|
+
const pointer = parse(runSchema, await this.ws.json(`${this.stateRoot}/active-run.json`));
|
|
472
|
+
const active = await this.ws.exists(`${this.stateRoot}/runs/${pointer.id}/run.json`) ? await this.runs.read(pointer.id) : pointer;
|
|
473
|
+
if (active.status === 'prepared') {
|
|
474
|
+
if (input.run_id !== active.id || input.artifact_dir !== active.artifact_dir)
|
|
475
|
+
fail('invalid_run', 'The active generation must be finalized with its matching run ID and output directory.');
|
|
476
|
+
run = active;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (input.run_id && !run)
|
|
480
|
+
fail('invalid_run', 'This generation run is not active.');
|
|
481
|
+
await this.runs.assertAvailable(input.run_id);
|
|
482
|
+
if (!run)
|
|
483
|
+
await this.setup.requireAnalysis();
|
|
484
|
+
if (run && input.section_id && input.section_id !== run.section_id)
|
|
485
|
+
fail('invalid_section', 'The section differs from the prepared generation context.');
|
|
486
|
+
const snap = await snapshot(this.ws, input.artifact_dir, run?.started_at);
|
|
487
|
+
const key = input.artifact_dir.split('/').at(-1);
|
|
488
|
+
const old = await this.ws.exists(`${this.stateRoot}/articles/${key}.json`) ? parse(localSchema, await this.ws.json(`${this.stateRoot}/articles/${key}.json`)) : undefined;
|
|
489
|
+
const destination = await this.ws.exists(`${this.stateRoot}/binding.json`) ? await this.binding() : undefined;
|
|
490
|
+
if (old?.project_id && (old.project_id !== destination?.project_id || old.api_origin !== destination?.api_origin))
|
|
491
|
+
fail('destination_mismatch', 'This saved article belongs to another help centre.');
|
|
492
|
+
let commit = null, dirty = true;
|
|
493
|
+
const sourceWorkspace = run?.codebase_dir ? await this.ws.resolve(run.codebase_dir) : this.ws.root;
|
|
494
|
+
try {
|
|
495
|
+
commit = (await exec('git', ['rev-parse', 'HEAD'], { cwd: sourceWorkspace, timeout: 5000 })).stdout.trim();
|
|
496
|
+
dirty = Boolean((await exec('git', ['status', '--porcelain', '--untracked-files=normal', '--', '.', ':!output', ':!.rtfm', ':!.rtfm-trace'], { cwd: sourceWorkspace, timeout: 5000 })).stdout.trim());
|
|
497
|
+
}
|
|
498
|
+
catch { /* non-git workspaces retain unknown/dirty provenance */ }
|
|
499
|
+
if (this.source) {
|
|
500
|
+
commit = this.source.source_commit;
|
|
501
|
+
dirty = this.source.source_dirty;
|
|
502
|
+
}
|
|
503
|
+
const previousManifest = old ? parse(manifestSchema, await this.ws.json(`${old.bundle_dir}/manifest.json`)) : undefined;
|
|
504
|
+
const manifest = createManifest(snap, { local_article_id: old?.local_article_id ?? run?.local_article_id ?? randomUUID(), run_id: run?.id ?? randomUUID(), skills_version: run?.skills_version ?? await this.version(), source_commit: commit, source_dirty: dirty, section_id: run ? run.section_id : input.section_id ?? previousManifest?.section_id ?? null });
|
|
505
|
+
const bundleDir = `${this.stateRoot}/bundles/${manifest.bundle_hash}`;
|
|
506
|
+
await this.ws.write(`${bundleDir}/article.json`, snap.articleBytes);
|
|
507
|
+
for (const image of snap.images)
|
|
508
|
+
await this.ws.write(`${bundleDir}/${image.filename}`, image.bytes);
|
|
509
|
+
await this.ws.writeJson(`${bundleDir}/manifest.json`, manifest);
|
|
510
|
+
const state = { ...(destination ? { project_id: destination.project_id, api_origin: destination.api_origin } : {}), local_article_id: manifest.local_article_id, artifact_dir: input.artifact_dir, bundle_dir: bundleDir, bundle_hash: manifest.bundle_hash, ...(old?.remote ? { remote: old.remote, uploaded_bundle_hash: old.uploaded_bundle_hash } : run?.progress?.article ? { remote: run.progress.article } : {}) };
|
|
511
|
+
await this.ws.writeJson(`${this.stateRoot}/articles/${key}.json`, state);
|
|
512
|
+
if (run) {
|
|
513
|
+
await this.runs.save({ ...run, status: 'finalized', phase: 'finalizing', bundle_dir: bundleDir, bundle_hash: manifest.bundle_hash, error: undefined });
|
|
514
|
+
}
|
|
515
|
+
return { status: 'finalized', ...state, warnings: snap.warnings };
|
|
516
|
+
}
|
|
517
|
+
/** Saved local articles, newest first: state files without a help centre plus their run titles. */
|
|
518
|
+
async localArticles() {
|
|
519
|
+
const items = [];
|
|
520
|
+
for (const entry of await this.ws.list(`${this.stateRoot}/articles`)) {
|
|
521
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
522
|
+
continue;
|
|
523
|
+
const state = localSchema.safeParse(await this.ws.json(`${this.stateRoot}/articles/${entry.name}`));
|
|
524
|
+
if (!state.success || state.data.project_id)
|
|
525
|
+
continue;
|
|
526
|
+
let title = entry.name.slice(0, -5);
|
|
527
|
+
try {
|
|
528
|
+
title = (await this.ws.json(`${state.data.bundle_dir}/article.json`)).title ?? title;
|
|
529
|
+
}
|
|
530
|
+
catch { /* Bundle removed; keep the slug. */ }
|
|
531
|
+
let run;
|
|
532
|
+
try {
|
|
533
|
+
const manifest = parse(manifestSchema, await this.ws.json(`${state.data.bundle_dir}/manifest.json`));
|
|
534
|
+
if (await this.ws.exists(`${this.stateRoot}/runs/${manifest.run_id}/run.json`))
|
|
535
|
+
run = await this.runs.read(manifest.run_id);
|
|
536
|
+
}
|
|
537
|
+
catch { /* A missing manifest still leaves an uploadable state file. */ }
|
|
538
|
+
items.push({ slug: entry.name.slice(0, -5), title, local_article_id: state.data.local_article_id, artifact_dir: state.data.artifact_dir, export_path: run?.export_path, run_id: run?.id });
|
|
539
|
+
}
|
|
540
|
+
return items.sort((a, b) => a.title.localeCompare(b.title));
|
|
541
|
+
}
|
|
542
|
+
async listLocal() {
|
|
543
|
+
if (!this.api.configured() || await this.destination() === 'local')
|
|
544
|
+
return localArticleInventory(this);
|
|
545
|
+
return this.lock(async () => {
|
|
546
|
+
const sync = new ProjectSync(this);
|
|
547
|
+
const result = await sync.refresh();
|
|
548
|
+
return result.status === 'ready' ? result : { ...result, articles: await sync.localRecords() };
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
async upload(slug) {
|
|
552
|
+
return this.lock(() => this.uploadUnlocked(slug));
|
|
553
|
+
}
|
|
554
|
+
/** Shared CLI/MCP saved-article upload; the lock keeps the destination stable for the batch. */
|
|
555
|
+
async uploadLocalArticles(slugs, onEvent) {
|
|
556
|
+
return this.lock(async () => {
|
|
557
|
+
await this.runs.assertAvailable();
|
|
558
|
+
const binding = await this.binding();
|
|
559
|
+
const articles = await this.localArticles();
|
|
560
|
+
if (!slugs)
|
|
561
|
+
return { status: articles.length ? 'selection_required' : 'up_to_date', project_id: binding.project_id, articles,
|
|
562
|
+
instructions: 'Choose the saved articles to upload as drafts, then call supportpages_publish with their slugs. This folder is now linked to this help centre. Public publication is a separate action.' };
|
|
563
|
+
const selected = [];
|
|
564
|
+
const already_uploaded = [];
|
|
565
|
+
// Validate the entire selection before sending any article.
|
|
566
|
+
for (const slug of new Set(slugs)) {
|
|
567
|
+
parse(idSchema, slug, 'invalid_article');
|
|
568
|
+
const item = articles.find(article => article.slug === slug);
|
|
569
|
+
if (item) {
|
|
570
|
+
selected.push(item);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const file = `${this.stateRoot}/articles/${slug}.json`;
|
|
574
|
+
const state = await this.ws.exists(file) ? parse(localSchema, await this.ws.json(file)) : undefined;
|
|
575
|
+
if (state?.project_id === binding.project_id && state.api_origin === binding.api_origin && state.remote && state.uploaded_bundle_hash === state.bundle_hash) {
|
|
576
|
+
already_uploaded.push({ slug, editor_url: state.remote.editor_url });
|
|
577
|
+
}
|
|
578
|
+
else
|
|
579
|
+
fail('invalid_article', 'Choose an article from the saved local article list.', { slug });
|
|
580
|
+
}
|
|
581
|
+
const uploaded = [];
|
|
582
|
+
const failed = [];
|
|
583
|
+
let stopped = false;
|
|
584
|
+
const skipped = [];
|
|
585
|
+
for (const item of selected) {
|
|
586
|
+
if (stopped) {
|
|
587
|
+
skipped.push(item.slug);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
onEvent?.({ slug: item.slug, title: item.title });
|
|
591
|
+
try {
|
|
592
|
+
const result = await this.uploadUnlocked(item.slug);
|
|
593
|
+
const saved = { slug: item.slug, title: item.title, editor_url: result.article.editor_url };
|
|
594
|
+
uploaded.push(saved);
|
|
595
|
+
onEvent?.(saved);
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
const problem = { slug: item.slug, title: item.title, error: publicError(error) };
|
|
599
|
+
failed.push(problem);
|
|
600
|
+
onEvent?.(problem);
|
|
601
|
+
stopped = problem.error.code === 'plan_limit';
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return { status: failed.length ? 'upload_incomplete' : 'drafts_uploaded', project_id: binding.project_id,
|
|
605
|
+
uploaded, already_uploaded, failed, skipped,
|
|
606
|
+
instructions: 'Show the editor links for uploaded drafts and any errors. Resolve upload failures and retry supportpages_publish without regenerating. Articles remain drafts; review them in SupportPages.io. This folder now sends new articles to this help centre.' };
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
async uploadUnlocked(slug) {
|
|
610
|
+
const binding = await this.binding();
|
|
611
|
+
const stateFile = `${this.stateRoot}/articles/${slug}.json`;
|
|
612
|
+
const state = parse(localSchema, await this.ws.json(stateFile));
|
|
613
|
+
if (state.project_id && (state.project_id !== binding.project_id || state.api_origin !== binding.api_origin))
|
|
614
|
+
fail('destination_mismatch', 'This saved article belongs to another help centre.');
|
|
615
|
+
await new ProjectSync(this).guard(state.local_article_id);
|
|
616
|
+
const { manifest, snap } = await verifyBundle(this.ws, state.bundle_dir);
|
|
617
|
+
const run = await this.ws.exists(`${this.stateRoot}/runs/${manifest.run_id}/run.json`) ? await this.runs.read(manifest.run_id) : undefined;
|
|
618
|
+
if (run?.project_id && (run.project_id !== binding.project_id || run.api_origin !== binding.api_origin))
|
|
619
|
+
fail('destination_mismatch', 'The run belongs to a different help centre.');
|
|
620
|
+
const form = new FormData();
|
|
621
|
+
form.set('manifest', JSON.stringify(manifest));
|
|
622
|
+
form.set('article', new Blob([new Uint8Array(snap.articleBytes)], { type: 'application/json' }), 'article.json');
|
|
623
|
+
for (const image of snap.images)
|
|
624
|
+
form.set(`images[${image.block_id}]`, new Blob([new Uint8Array(image.bytes)], { type: 'image/png' }), image.filename);
|
|
625
|
+
if (run?.progress?.article)
|
|
626
|
+
form.set('generation_attempt', run.progress.attempt);
|
|
627
|
+
const destination = run?.progress?.article ?? state.remote;
|
|
628
|
+
if (destination) {
|
|
629
|
+
form.set('article_id', destination.id);
|
|
630
|
+
form.set('expected_revision', destination.revision);
|
|
631
|
+
}
|
|
632
|
+
const result = parse(importResponseSchema, await this.api.request('POST', `/projects/${binding.project_id}/article_imports`, form, manifest.bundle_hash), 'invalid_response');
|
|
633
|
+
// An article written before this folder had a help centre now belongs to it.
|
|
634
|
+
const adopted = { project_id: binding.project_id, api_origin: binding.api_origin };
|
|
635
|
+
await this.ws.writeJson(stateFile, { ...state, ...adopted, remote: result.article, uploaded_bundle_hash: manifest.bundle_hash });
|
|
636
|
+
if (run)
|
|
637
|
+
await this.runs.save({ ...run, ...adopted, phase: 'ready_for_review', remote: result.article, error: undefined });
|
|
638
|
+
return result;
|
|
639
|
+
}
|
|
640
|
+
async complete(input, options = {}) {
|
|
641
|
+
if (!input.completed)
|
|
642
|
+
fail('incomplete_generation', 'Confirm successful completion of the entire writing task.');
|
|
643
|
+
this.relay.stop();
|
|
644
|
+
return this.lock(async () => {
|
|
645
|
+
let run = await this.runs.read(input.run_id);
|
|
646
|
+
if (!['prepared', 'finalized'].includes(run.status))
|
|
647
|
+
fail('incomplete_generation', 'Failed, cancelled or interrupted runs cannot be delivered.');
|
|
648
|
+
if (!run.project_id)
|
|
649
|
+
return this.completeLocallyUnlocked(run, options.deliver !== false);
|
|
650
|
+
const binding = await this.binding();
|
|
651
|
+
if (run.project_id && (run.project_id !== binding.project_id || run.api_origin !== binding.api_origin))
|
|
652
|
+
fail('destination_mismatch', 'The run belongs to a different help centre.');
|
|
653
|
+
const slug = run.artifact_dir.split('/').at(-1);
|
|
654
|
+
if (run.remote) {
|
|
655
|
+
const synced = await new ProjectSync(this).refresh(undefined, false);
|
|
656
|
+
const item = synced.status === 'ready' ? synced.articles.find(item => item.remote?.id === run.remote.id) : undefined;
|
|
657
|
+
if (item?.sync_status === 'deleted_remotely')
|
|
658
|
+
fail('article_deleted', 'This article was deleted remotely. Restore it in SupportPages.io before continuing.');
|
|
659
|
+
if (item?.sync_status === 'unavailable')
|
|
660
|
+
fail('article_unavailable', 'The article is unavailable. Check its project and access.');
|
|
661
|
+
const delivered = item?.remote && 'revision' in item.remote ? { ...run, remote: item.remote } : run;
|
|
662
|
+
return options.deliver === false ? { status: 'ready_for_review', editor_url: delivered.remote.editor_url } : this.delivery(delivered);
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
await new ProjectSync(this).guard(run.local_article_id);
|
|
666
|
+
if (run.status === 'prepared') {
|
|
667
|
+
await this.runs.save({ ...run, phase: 'finalizing', error: undefined });
|
|
668
|
+
await this.finalizeUnlocked({ artifact_dir: run.artifact_dir, run_id: run.id, completed: true });
|
|
669
|
+
run = await this.runs.read(run.id);
|
|
670
|
+
}
|
|
671
|
+
if (run.progress) {
|
|
672
|
+
await this.relay.ensureStartedUnlocked(run.id);
|
|
673
|
+
await this.relay.sendPendingUnlocked(run.id);
|
|
674
|
+
run = await this.runs.read(run.id);
|
|
675
|
+
}
|
|
676
|
+
const state = parse(localSchema, await this.ws.json(`${this.stateRoot}/articles/${slug}.json`));
|
|
677
|
+
const { manifest } = await verifyBundle(this.ws, state.bundle_dir);
|
|
678
|
+
if (manifest.run_id !== run.id || state.local_article_id !== run.local_article_id || (run.bundle_hash && state.bundle_hash !== run.bundle_hash))
|
|
679
|
+
fail('stale_artifact', 'This run’s finalized article was replaced. Do not upload a different revision.');
|
|
680
|
+
await this.runs.save({ ...run, phase: 'uploading', error: undefined });
|
|
681
|
+
// Reconcile a previous upload that saved article state before run state.
|
|
682
|
+
if (state.remote && state.uploaded_bundle_hash === state.bundle_hash) {
|
|
683
|
+
await this.runs.save({ ...run, phase: 'ready_for_review', remote: state.remote });
|
|
684
|
+
}
|
|
685
|
+
else
|
|
686
|
+
await this.uploadUnlocked(slug);
|
|
687
|
+
const delivered = await this.runs.read(run.id);
|
|
688
|
+
trackArticle(delivered, 'succeeded');
|
|
689
|
+
return options.deliver === false ? { status: 'ready_for_review', editor_url: delivered.remote.editor_url } : this.delivery(delivered);
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
const safe = publicError(error);
|
|
693
|
+
const current = await this.runs.read(run.id);
|
|
694
|
+
const phase = current.status === 'finalized' ? 'upload_failed' : 'failed';
|
|
695
|
+
const failed = { ...current, phase: phase, error: { code: safe.code, message: safe.message } };
|
|
696
|
+
await this.runs.save(failed);
|
|
697
|
+
trackArticle(current, phase, safe.code);
|
|
698
|
+
if (phase === 'upload_failed') {
|
|
699
|
+
const detail = safe.details && typeof safe.details === 'object' ? safe.details : {};
|
|
700
|
+
fail(safe.code, `${safe.message} The article is generated, but delivery to the editor could not be confirmed. Retry delivery without regenerating it.`, {
|
|
701
|
+
run_id: current.id, phase, generation_completed_locally: true,
|
|
702
|
+
editor_url: detail.editor_url ?? current.progress?.article?.editor_url,
|
|
703
|
+
remote_state: current.progress?.article ? 'preview_previously_confirmed' : 'unconfirmed',
|
|
704
|
+
instructions: uploadRecovery(failed),
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
/** A run written without a help centre is finalized and exported instead of uploaded. Safe to repeat. */
|
|
712
|
+
async completeLocallyUnlocked(run, deliver = true) {
|
|
713
|
+
const local = await this.local();
|
|
714
|
+
if (!local)
|
|
715
|
+
fail('local_required', `This article was written for local saving, but the folder no longer has local settings. Run ${CLI_NAME} init.`);
|
|
716
|
+
if (run.phase === 'saved' && run.export_path)
|
|
717
|
+
return this.savedLocally(run, local, undefined, deliver);
|
|
718
|
+
try {
|
|
719
|
+
if (run.status === 'prepared') {
|
|
720
|
+
await this.runs.save({ ...run, phase: 'finalizing', error: undefined });
|
|
721
|
+
await this.finalizeUnlocked({ artifact_dir: run.artifact_dir, run_id: run.id, completed: true });
|
|
722
|
+
run = await this.runs.read(run.id);
|
|
723
|
+
}
|
|
724
|
+
const slug = run.artifact_dir.split('/').at(-1);
|
|
725
|
+
const state = parse(localSchema, await this.ws.json(`${this.stateRoot}/articles/${slug}.json`));
|
|
726
|
+
const { manifest, snap } = await verifyBundle(this.ws, state.bundle_dir);
|
|
727
|
+
if (manifest.run_id !== run.id || state.local_article_id !== run.local_article_id || (run.bundle_hash && state.bundle_hash !== run.bundle_hash))
|
|
728
|
+
fail('stale_artifact', 'This run’s finalized article was replaced. Complete the current article instead.');
|
|
729
|
+
const directory = run.local_export?.directory ?? local.export_dir;
|
|
730
|
+
const exported = await exportArticle(this.ws, snap, run.artifact_dir, directory, slug);
|
|
731
|
+
run = { ...run, phase: 'saved', export_path: exported.markdown_path, local_export: { directory }, error: undefined };
|
|
732
|
+
await this.runs.save(run);
|
|
733
|
+
trackArticle(run, 'succeeded');
|
|
734
|
+
return this.savedLocally(run, local, exported.image_count, deliver);
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
const safe = publicError(error);
|
|
738
|
+
const current = await this.runs.read(run.id);
|
|
739
|
+
// A finalized bundle whose export failed can be exported again; an
|
|
740
|
+
// unfinished bundle is a failed writer.
|
|
741
|
+
await this.runs.save({ ...current, phase: current.status === 'finalized' ? 'finalizing' : 'failed', error: { code: safe.code, message: safe.message } });
|
|
742
|
+
trackArticle(current, 'failed', safe.code);
|
|
743
|
+
throw error;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
async savedLocally(run, local, imageCount, deliver = true) {
|
|
747
|
+
const slug = run.artifact_dir.split('/').at(-1);
|
|
748
|
+
let preview_uri;
|
|
749
|
+
try {
|
|
750
|
+
preview_uri = (await this.preview(run.artifact_dir)).uri;
|
|
751
|
+
}
|
|
752
|
+
catch { /* The Markdown export is the deliverable. */ }
|
|
753
|
+
const defaultFolder = (run.local_export?.directory ?? local.export_dir).replace(/\/+$/, '') === 'output/articles';
|
|
754
|
+
// The relay's early completion leaves the one-time invitation for the
|
|
755
|
+
// parent's complete_article call, like the hosted publication question.
|
|
756
|
+
// A replay returns the invitation this article already got, so a repeated
|
|
757
|
+
// complete_article call cannot lose it.
|
|
758
|
+
let hosting_invitation = deliver && run.hosting_reminder_checked ? run.hosting_invitation : undefined;
|
|
759
|
+
if (deliver && !run.hosting_reminder_checked) {
|
|
760
|
+
try {
|
|
761
|
+
// Mark this delivery checked even if the optional device journal fails.
|
|
762
|
+
await this.runs.save({ ...run, hosting_reminder_checked: true });
|
|
763
|
+
hosting_invitation = await new HostingReminders(this.configDir).complete({ origin: this.api.origin, articleId: run.local_article_id });
|
|
764
|
+
if (hosting_invitation)
|
|
765
|
+
await this.runs.save({ ...run, hosting_reminder_checked: true, hosting_invitation });
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
hosting_invitation = undefined; /* Reminders must never turn a saved article into a failure. */
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return { status: 'saved', run_id: run.id, slug, artifact_dir: run.artifact_dir, markdown_path: run.export_path,
|
|
772
|
+
export_dir: path.dirname(run.export_path), image_count: imageCount, preview_uri, ...articleLink(run), local: true,
|
|
773
|
+
...(hosting_invitation ? { hosting_invitation, show_to_user: hostingShowText(hosting_invitation) } : {}),
|
|
774
|
+
instructions: [
|
|
775
|
+
...(hosting_invitation ? ['Show show_to_user verbatim as its own paragraph, right after the saved path. It is part of this result, not optional commentary.'] : []),
|
|
776
|
+
localChatInstruction,
|
|
777
|
+
`The article is saved. Show ${run.export_path} as the deliverable in your completion message, with the number of screenshots when known.`,
|
|
778
|
+
'Do not automatically ask whether to publish or start sign-in. If the user explicitly asks to host the saved articles, use supportpages_publish.',
|
|
779
|
+
...(hosting_invitation
|
|
780
|
+
? ['If the user then asks to host it, ask whether to sign in or create a free SupportPages.io account and call supportpages_publish. Never start sign-in or upload without that request, and never ask another mandatory question.']
|
|
781
|
+
: ['Do not add a hosting invitation to this completion. If the user asks to host or share it, ask whether to sign in or create a free SupportPages.io account, then use supportpages_publish.']),
|
|
782
|
+
'If the user asks to stop hosting reminders, call supportpages_set_hosting_reminders with enabled=false. Enable them again only when requested.',
|
|
783
|
+
...(defaultFolder ? ['The output folder is usually ignored by git: suggest copying index.md and its screenshots into the docs.'] : []),
|
|
784
|
+
] };
|
|
785
|
+
}
|
|
786
|
+
async delivery(run) {
|
|
787
|
+
if (!run.remote)
|
|
788
|
+
fail('not_uploaded', 'The run has no uploaded draft.');
|
|
789
|
+
// A replay returns the invitation this draft already got.
|
|
790
|
+
let repository_invitation = run.repository_reminder_checked ? run.repository_invitation : undefined;
|
|
791
|
+
let repository_connection;
|
|
792
|
+
if (!run.repository_reminder_checked) {
|
|
793
|
+
try {
|
|
794
|
+
const binding = await this.binding();
|
|
795
|
+
try {
|
|
796
|
+
const fresh = parse(contextSchema, await this.api.request('GET', `/projects/${binding.project_id}/context`, undefined, undefined, 3000), 'invalid_response');
|
|
797
|
+
if (fresh.project.id === binding.project_id)
|
|
798
|
+
repository_connection = fresh.repository_connection;
|
|
799
|
+
}
|
|
800
|
+
catch { /* Connection lookup is optional; delivery is already confirmed. */ }
|
|
801
|
+
// Without a known connection state this article is not counted, so a failed
|
|
802
|
+
// lookup never uses up a reminder slot; a later completion checks again.
|
|
803
|
+
if (!repository_connection)
|
|
804
|
+
throw Error('Repository connection state unknown.');
|
|
805
|
+
// Mark this delivery checked even if the optional global journal fails.
|
|
806
|
+
await this.runs.save({ ...run, repository_reminder_checked: true });
|
|
807
|
+
repository_invitation = await new RepositoryReminders(this.configDir).complete({
|
|
808
|
+
origin: this.api.origin, projectId: binding.project_id, articleId: run.local_article_id,
|
|
809
|
+
connection: run.remote.status === 'draft' ? repository_connection : undefined,
|
|
810
|
+
firstWriterStartedAt: run.first_writer_started_at, readyAt: run.ready_for_review_at,
|
|
811
|
+
});
|
|
812
|
+
if (repository_invitation)
|
|
813
|
+
await this.runs.save({ ...run, repository_reminder_checked: true, repository_invitation });
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
repository_invitation = undefined; /* Reminders must never turn a saved draft into an upload failure. */
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return { status: run.remote.status === 'published' ? 'published' : 'ready_for_review', run_id: run.id,
|
|
820
|
+
slug: run.artifact_dir.split('/').at(-1), article: run.remote, editor_url: run.remote.editor_url,
|
|
821
|
+
repository_connection, ...(repository_invitation ? { repository_invitation, show_to_user: repositoryShowText(repository_invitation) } : {}),
|
|
822
|
+
open_when_ready: Boolean(run.open_when_ready && !run.progress?.open_attempted),
|
|
823
|
+
instructions: [
|
|
824
|
+
...(repository_invitation ? ['Show show_to_user verbatim as its own paragraph, right after the draft review link. It is part of this result, not optional commentary.'] : []),
|
|
825
|
+
articleChatInstruction,
|
|
826
|
+
'Always show the editor link. Browser-opening failure does not invalidate this saved draft.',
|
|
827
|
+
...(repository_invitation
|
|
828
|
+
? ['Never open the repository connection page automatically or ask another mandatory question.']
|
|
829
|
+
: ['Do not add a repository connection invitation to this completion.']),
|
|
830
|
+
'If the user asks to stop repository reminders, call supportpages_set_repository_reminders with enabled=false. Enable them again only when requested.',
|
|
831
|
+
run.open_when_ready && !run.progress?.open_attempted ? 'Opening when ready is enabled for this run: use the host’s browser-opening capability with editor_url. If unavailable, leave the clickable link. Never automate the Publish button.' : 'Do not open a browser unless the user asks.',
|
|
832
|
+
run.remote.status === 'published' ? 'Return the public URL; this article is already published.' : 'Ask once: Would you like me to publish it? Wait for an affirmative response for this draft; otherwise leave it unpublished.',
|
|
833
|
+
'If authorized, call supportpages_publish_article with the returned slug and article.revision. If publishing permission is missing, provide the editor link for browser publication. On revision conflict preserve the browser edits and direct the user to publish there; do not re-upload.'
|
|
834
|
+
] };
|
|
835
|
+
}
|
|
836
|
+
async reportProgressEvent(run, event) {
|
|
837
|
+
if (!run.progress?.article)
|
|
838
|
+
return;
|
|
839
|
+
try {
|
|
840
|
+
await this.api.request('POST', `/projects/${run.project_id}/generation_runs/${run.id}/event`, { event, attempt: run.progress.attempt });
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
await this.runs.save({ ...run, progress: { ...run.progress, sync_error: publicError(error).message } });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async retryArticle(input) {
|
|
847
|
+
if (!input.stopped)
|
|
848
|
+
fail('writer_not_stopped', 'Stop the previous writer before retrying.');
|
|
849
|
+
this.relay.stop();
|
|
850
|
+
return this.lock(async () => {
|
|
851
|
+
let run = await this.runs.read(input.run_id);
|
|
852
|
+
const local = !run.project_id;
|
|
853
|
+
if (local) {
|
|
854
|
+
if (await this.destination() !== 'local')
|
|
855
|
+
fail('destination_mismatch', 'This article was written for local saving, but the folder now has a help centre. Prepare it again as a new article.');
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
const binding = await this.binding();
|
|
859
|
+
if (run.project_id !== binding.project_id || run.api_origin !== binding.api_origin)
|
|
860
|
+
fail('destination_mismatch', 'This article belongs to a different help centre. Switch to that project before resuming it.');
|
|
861
|
+
}
|
|
862
|
+
const context = await this.contextFor(local ? 'local' : 'hosted');
|
|
863
|
+
if (!local)
|
|
864
|
+
await new ProjectSync(this).guard(run.local_article_id, context);
|
|
865
|
+
if (run.status === 'finalized' || run.remote)
|
|
866
|
+
fail('invalid_run', 'Retry complete_article for a finished bundle; do not regenerate it.');
|
|
867
|
+
const writerModel = (await executionSettings(this, 'claude')).model;
|
|
868
|
+
const codexWriter = await executionSettings(this, 'codex');
|
|
869
|
+
await this.runs.assertAvailable(run.id);
|
|
870
|
+
if (!run.progress && !local)
|
|
871
|
+
checkArticleCapacity(context.article_capacity);
|
|
872
|
+
if (run.progress && !run.progress.article) {
|
|
873
|
+
// The server checks capacity only when creating a new preview. Looking up
|
|
874
|
+
// an existing run must still work if its earlier creation filled the plan.
|
|
875
|
+
try {
|
|
876
|
+
await this.relay.ensureStartedUnlocked(run.id);
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
if (publicError(error).code !== 'run_closed')
|
|
880
|
+
throw error;
|
|
881
|
+
}
|
|
882
|
+
run = await this.runs.read(run.id);
|
|
883
|
+
}
|
|
884
|
+
if (run.progress?.article) {
|
|
885
|
+
const next = run.progress.next_attempt ?? randomUUID();
|
|
886
|
+
await this.runs.save({ ...run, progress: { ...run.progress, next_attempt: next } });
|
|
887
|
+
const result = parse(progressResponse, await this.api.request('POST', `/projects/${run.project_id}/generation_runs/${run.id}/event`, { event: 'resume', attempt: run.progress.attempt, next_attempt: next }), 'invalid_response');
|
|
888
|
+
run = { ...run, progress: { attempt: next, sequence: 0, article: result.article, open_attempted: run.progress.open_attempted, browser_opened: run.progress.browser_opened } };
|
|
889
|
+
}
|
|
890
|
+
run = { ...run, status: 'prepared', phase: 'prepared', session_id: this.sessionId, error: undefined, host_task_id: undefined,
|
|
891
|
+
execution_mode: undefined, writer_token: randomUUID(), writer_started_at: undefined, writer_completed_at: undefined };
|
|
892
|
+
await this.runs.save(run);
|
|
893
|
+
await this.ws.writeJson(`${this.stateRoot}/active-run.json`, run);
|
|
894
|
+
const entrypoint = writerEntry(this.ws.root, this.stateRoot, run.id, this.skillsDir, run.writer_token);
|
|
895
|
+
const contextPath = `${this.stateRoot}/runs/${run.id}/context.json`;
|
|
896
|
+
const previousContext = await this.ws.json(contextPath);
|
|
897
|
+
const contextFile = await this.ws.writeJson(contextPath, { ...previousContext, ...context,
|
|
898
|
+
project_overview: previousContext.project_overview, analysis_summary: previousContext.analysis_summary, project_name: context.project.name });
|
|
899
|
+
const writerWorkspace = await this.ws.resolve(run.codebase_dir ?? '.');
|
|
900
|
+
const outputPath = await this.ws.resolve(run.artifact_dir);
|
|
901
|
+
const link = articleLink(run);
|
|
902
|
+
this.relay.start(run.id);
|
|
903
|
+
return { run_id: run.id, ...link, prefer_background: run.prefer_background, ...(local ? { local: true, link_instructions: localChatInstruction } : {}),
|
|
904
|
+
task_brief: { name: writerAgentName, claude_model: writerModel, codex_model: codexWriter.model, codex_reasoning_effort: codexWriter.effort,
|
|
905
|
+
prefer_background: run.prefer_background, reporting_instructions: writerReportingInstruction(run.prefer_background === true),
|
|
906
|
+
run_id: run.id, entrypoint, execution: entrypoint.execution, completion: entrypoint.completion, editor_url: link.editor_url, writer_instructions: claudeWriterAgents[writerAgentType].prompt, workspace: writerWorkspace, artifact_dir: run.codebase_dir && run.codebase_dir !== '.' ? outputPath : run.artifact_dir,
|
|
907
|
+
environment: { RTFM_WORKSPACE: writerWorkspace, RTFM_CONTEXT_FILE: contextFile, RTFM_OUTPUT_DIR: outputPath, RTFM_SKILLS_DIR: this.skillsDir, RTFM_MAX_IMAGES: String(writerScreenshotLimit), PATH: path.dirname(process.execPath) + path.delimiter + (process.env.PATH ?? '') },
|
|
908
|
+
instructions: [entrypoint.instruction, entrypoint.execution.instruction, `The application and its .rtfm cache are in ${writerWorkspace}. Use ${outputPath} as OUT for every skill command instead of the default ./output/articles directory.`, `Follow the article instructions returned by the entry check. Resume this article in ${outputPath}; retain valid prose and images, finish missing or failed work, rerun final rendering and fidelity validation. Do not edit app source, upload, publish or call MCP tools.`, entrypoint.completion.instruction] },
|
|
909
|
+
instructions: `${local ? localChatInstruction : link.link_instructions} ${writerLaunchInstruction} ${codexWriterLaunchInstruction} ${backgroundParentInstruction} Launch this recovery task using the host coding agent without worktree isolation, then report started with update_run. On success call complete_article.${local ? ' The article is saved locally when complete; there is no preview link.' : ' Show the same preview link; do not open a second tab.'}` };
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
get hosted() { return new HostedOperations(this); }
|
|
913
|
+
async createArticle(input) {
|
|
914
|
+
if (input.recommendation_id && input.title || !input.recommendation_id && !input.title)
|
|
915
|
+
fail('invalid_request', 'Provide either a title or an accepted recommendation_id.');
|
|
916
|
+
const decision = await resolveAction(this, 'create_article');
|
|
917
|
+
if (!decision.allowed)
|
|
918
|
+
requireExecution(decision, decision.execution);
|
|
919
|
+
if (decision.execution === 'local') {
|
|
920
|
+
if (await this.destination() === 'none' && !this.api.configured())
|
|
921
|
+
await this.saveLocal({ version: 1, export_dir: 'output/articles' });
|
|
922
|
+
if (!input.title || input.recommendation_id)
|
|
923
|
+
fail('invalid_request', 'Local creation requires an article title.');
|
|
924
|
+
const prepared = await this.prepare({ ...input, title: input.title });
|
|
925
|
+
return { ...prepared, publication_requested: input.publish === true };
|
|
926
|
+
}
|
|
927
|
+
return this.hosted.submit('create_article', input.recommendation_id ? { recommendation_id: input.recommendation_id, publish: input.publish ?? false } : { title: input.title, description: input.description, article_type: input.article_type ?? 'how-to', publish: input.publish ?? false }, { ...input, decision });
|
|
928
|
+
}
|
|
929
|
+
async editArticle(input) {
|
|
930
|
+
const decision = await resolveAction(this, 'edit_article', input.article_id);
|
|
931
|
+
if (!decision.allowed)
|
|
932
|
+
requireExecution(decision, decision.execution);
|
|
933
|
+
if (decision.execution === 'local') {
|
|
934
|
+
const article = await this.getArticle(input.article_id);
|
|
935
|
+
if (article.revision !== input.expected_revision)
|
|
936
|
+
fail('revision_conflict', 'Read the current article before editing.');
|
|
937
|
+
return { status: 'local_edit_required', execution: 'local', article, editing_instructions: input.instructions,
|
|
938
|
+
next_tool: 'supportpages_update_article', expected_revision: input.expected_revision,
|
|
939
|
+
instructions: 'Apply the requested edit locally to the returned content. Preserve unchanged text, image references and content format, then call update_article with this article_id and expected_revision. Do not create another article. A failed save or conflict leaves the existing article unchanged.' };
|
|
940
|
+
}
|
|
941
|
+
return this.hosted.submit('edit_article', { article_id: input.article_id, expected_revision: input.expected_revision, instructions: input.instructions }, { ...input, decision });
|
|
942
|
+
}
|
|
943
|
+
async createWalkthrough(input) {
|
|
944
|
+
const decision = await resolveAction(this, 'create_video_walkthrough', input.article_id);
|
|
945
|
+
if (!decision.allowed)
|
|
946
|
+
return { status: 'action_required', ...decision };
|
|
947
|
+
if (!input.article_id)
|
|
948
|
+
return { status: 'article_required', instructions: 'Choose a completed article using list_articles, then request its walkthrough with article_id.' };
|
|
949
|
+
const article = await this.getArticle(input.article_id);
|
|
950
|
+
return this.hosted.submit('create_video_walkthrough', { article_id: input.article_id, expected_revision: input.expected_revision ?? article.revision }, { ...input, decision });
|
|
951
|
+
}
|
|
952
|
+
async listSuggestions(kind, afterId) {
|
|
953
|
+
requireExecution(await resolveAction(this, 'read_articles'), 'remote');
|
|
954
|
+
if (afterId)
|
|
955
|
+
parse(remoteId, afterId);
|
|
956
|
+
const binding = await this.binding();
|
|
957
|
+
const item = kind === 'sections' ? sectionSuggestion : articleSuggestion;
|
|
958
|
+
return parse(z.object({ kind: z.literal(kind), items: z.array(item), next_cursor: remoteId.nullable() }), await this.api.request('GET', `/projects/${binding.project_id}/writer/${kind}${afterId ? `?after_id=${afterId}` : ''}`), 'invalid_response');
|
|
959
|
+
}
|
|
960
|
+
async reviewSuggestion(kind, id, decision) {
|
|
961
|
+
parse(remoteId, id);
|
|
962
|
+
requireExecution(await resolveAction(this, kind === 'sections' ? 'review_sections' : 'review_recommendations'), 'remote');
|
|
963
|
+
const binding = await this.binding();
|
|
964
|
+
const raw = await this.api.request('PATCH', `/projects/${binding.project_id}/writer/${kind}/${id}`, { decision });
|
|
965
|
+
const item = kind === 'sections' ? parse(sectionSuggestion, raw, 'invalid_response') : parse(articleSuggestion, raw, 'invalid_response');
|
|
966
|
+
if (item.id !== id)
|
|
967
|
+
fail('invalid_response', 'The server returned another suggestion.');
|
|
968
|
+
return item;
|
|
969
|
+
}
|
|
970
|
+
async listArticles(afterId, source = 'auto') {
|
|
971
|
+
if (source === 'local' && afterId)
|
|
972
|
+
fail('invalid_request', 'Local article listings do not use a hosted cursor.');
|
|
973
|
+
if (source === 'local' || source === 'auto' && !afterId && (!this.api.configured() || await this.destination() === 'local'))
|
|
974
|
+
return localArticleInventory(this);
|
|
975
|
+
const decision = await resolveAction(this, 'read_articles');
|
|
976
|
+
if (source === 'auto' && !afterId && decision.next_step?.code === 'authentication_required')
|
|
977
|
+
return localArticleInventory(this);
|
|
978
|
+
requireExecution(decision, 'remote');
|
|
979
|
+
if (afterId)
|
|
980
|
+
parse(remoteId, afterId);
|
|
981
|
+
const binding = await this.binding();
|
|
982
|
+
return parse(z.object({ articles: z.array(remoteArticleSchema.passthrough()), next_cursor: remoteId.nullable() }), await this.api.request('GET', `/projects/${binding.project_id}/articles${afterId ? `?after_id=${afterId}` : ''}`), 'invalid_response');
|
|
983
|
+
}
|
|
984
|
+
async mutateArticle(action, input) {
|
|
985
|
+
parse(remoteId, input.article_id);
|
|
986
|
+
if (!/^[a-f0-9]{64}$/.test(input.expected_revision))
|
|
987
|
+
fail('invalid_revision', 'Use the revision returned by get_article.');
|
|
988
|
+
requireExecution(await resolveAction(this, action, input.article_id), 'remote');
|
|
989
|
+
const binding = await this.binding();
|
|
990
|
+
const route = `/projects/${binding.project_id}/articles/${input.article_id}`;
|
|
991
|
+
const method = action === 'update_article' ? 'PATCH' : action === 'delete_article' ? 'DELETE' : 'POST';
|
|
992
|
+
const suffix = action === 'publish_article' ? '/publish' : action === 'unpublish_article' ? '/unpublish' : '';
|
|
993
|
+
const { article_id, ...body } = input;
|
|
994
|
+
const response = await this.api.request(method, route + suffix, body);
|
|
995
|
+
const result = action === 'delete_article'
|
|
996
|
+
? parse(z.object({ id: remoteId, deleted_at: z.iso.datetime() }), response, 'invalid_response')
|
|
997
|
+
: parse(remoteArticleSchema.passthrough(), response, 'invalid_response');
|
|
998
|
+
if (result.id !== article_id)
|
|
999
|
+
fail('invalid_response', 'The server returned a different article.');
|
|
1000
|
+
return result;
|
|
1001
|
+
}
|
|
1002
|
+
async getArticle(articleId) {
|
|
1003
|
+
parse(remoteId, articleId);
|
|
1004
|
+
const binding = await this.binding();
|
|
1005
|
+
const result = parse(remoteArticleSchema.passthrough(), await this.api.request('GET', `/projects/${binding.project_id}/articles/${articleId}`), 'invalid_response');
|
|
1006
|
+
if (result.id !== articleId)
|
|
1007
|
+
fail('invalid_response', 'The server returned a different article.');
|
|
1008
|
+
return result;
|
|
1009
|
+
}
|
|
1010
|
+
async publish(slug, expectedRevision) {
|
|
1011
|
+
return this.lock(async () => {
|
|
1012
|
+
const binding = await this.binding();
|
|
1013
|
+
const stateFile = `${this.stateRoot}/articles/${slug}.json`;
|
|
1014
|
+
const state = parse(localSchema, await this.ws.json(stateFile));
|
|
1015
|
+
if (!state.remote)
|
|
1016
|
+
fail('not_uploaded', 'Upload this article as a draft before publishing.');
|
|
1017
|
+
if (state.uploaded_bundle_hash !== state.bundle_hash)
|
|
1018
|
+
fail('not_uploaded', 'Upload the newly finalized bundle before publishing it.');
|
|
1019
|
+
if (state.remote.revision !== expectedRevision)
|
|
1020
|
+
fail('revision_conflict', 'The requested revision differs from the last uploaded revision.');
|
|
1021
|
+
// A newer local bundle must be uploaded before publishing it.
|
|
1022
|
+
await verifyBundle(this.ws, state.bundle_dir);
|
|
1023
|
+
const result = parse(remoteArticleSchema, await this.api.request('POST', `/projects/${binding.project_id}/articles/${state.remote.id}/publish`, { expected_revision: expectedRevision, bundle_hash: state.bundle_hash }), 'invalid_response');
|
|
1024
|
+
await this.ws.writeJson(stateFile, { ...state, remote: result });
|
|
1025
|
+
const { manifest } = await verifyBundle(this.ws, state.bundle_dir);
|
|
1026
|
+
if (await this.ws.exists(`${this.stateRoot}/runs/${manifest.run_id}/run.json`)) {
|
|
1027
|
+
const run = await this.runs.read(manifest.run_id);
|
|
1028
|
+
await this.runs.save({ ...run, remote: result, phase: 'published' });
|
|
1029
|
+
}
|
|
1030
|
+
return result;
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
const sectionSuggestion = z.object({ id: remoteId, name: z.string(), slug: z.string(), description: z.string().nullable(), justification: z.string().nullable(), status: z.enum(['pending', 'accepted', 'rejected']), visible: z.boolean() });
|
|
1035
|
+
const articleSuggestion = z.object({ id: remoteId, section_id: remoteId.nullable(), title: z.string(), description: z.string().nullable(), justification: z.string().nullable(), article_type: articleType, status: z.enum(['pending', 'accepted', 'rejected', 'generated']), article_id: remoteId.nullable() });
|
|
1036
|
+
//# sourceMappingURL=bridge.js.map
|