thumbgate 0.9.13 → 1.0.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.
Files changed (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +6 -3
  5. package/adapters/README.md +1 -1
  6. package/adapters/chatgpt/openapi.yaml +105 -0
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/codex/config.toml +2 -2
  9. package/adapters/forge/forge.yaml +28 -0
  10. package/adapters/mcp/server-stdio.js +32 -1
  11. package/adapters/opencode/opencode.json +1 -1
  12. package/bin/cli.js +53 -3
  13. package/config/mcp-allowlists.json +10 -0
  14. package/openapi/openapi.yaml +105 -0
  15. package/package.json +4 -4
  16. package/plugins/amp-skill/INSTALL.md +3 -4
  17. package/plugins/amp-skill/SKILL.md +0 -1
  18. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  19. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  20. package/plugins/claude-skill/INSTALL.md +1 -2
  21. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  22. package/plugins/codex-profile/.mcp.json +1 -1
  23. package/plugins/codex-profile/INSTALL.md +1 -1
  24. package/plugins/codex-profile/README.md +1 -1
  25. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  26. package/plugins/opencode-profile/INSTALL.md +1 -1
  27. package/public/blog.html +1 -0
  28. package/public/dashboard.html +1 -1
  29. package/public/guide.html +1 -1
  30. package/public/index.html +29 -5
  31. package/public/learn/agent-harness-pattern.html +1 -1
  32. package/public/learn/ai-agent-persistent-memory.html +1 -1
  33. package/public/learn/mcp-pre-action-gates-explained.html +1 -1
  34. package/public/learn/stop-ai-agent-force-push.html +1 -1
  35. package/public/learn/vibe-coding-safety-net.html +1 -1
  36. package/public/learn.html +62 -1
  37. package/public/lessons.html +1 -1
  38. package/public/pro.html +1 -1
  39. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  40. package/scripts/agent-security-hardening.js +4 -4
  41. package/scripts/async-job-runner.js +84 -24
  42. package/scripts/auto-wire-hooks.js +59 -1
  43. package/scripts/context-manager.js +330 -0
  44. package/scripts/dashboard.js +1 -1
  45. package/scripts/distribution-surfaces.js +12 -0
  46. package/scripts/ensure-repo-bootstrap.js +15 -14
  47. package/scripts/feedback-history-distiller.js +7 -1
  48. package/scripts/feedback-loop.js +10 -4
  49. package/scripts/feedback-paths.js +142 -10
  50. package/scripts/feedback-root-consolidator.js +18 -4
  51. package/scripts/gates-engine.js +96 -10
  52. package/scripts/hook-auto-capture.sh +1 -1
  53. package/scripts/hosted-job-launcher.js +260 -0
  54. package/scripts/managed-dpo-export.js +91 -0
  55. package/scripts/obsidian-export.js +0 -1
  56. package/scripts/operational-integrity.js +50 -7
  57. package/scripts/post-everywhere.js +10 -0
  58. package/scripts/prove-lancedb.js +62 -4
  59. package/scripts/publish-decision.js +16 -0
  60. package/scripts/self-healing-check.js +6 -1
  61. package/scripts/seo-gsd.js +217 -4
  62. package/scripts/social-analytics/load-env.js +33 -2
  63. package/scripts/social-analytics/store.js +200 -2
  64. package/scripts/statusline-cache-path.js +9 -6
  65. package/scripts/sync-version.js +18 -11
  66. package/scripts/tool-registry.js +37 -0
  67. package/scripts/train_from_feedback.py +0 -4
  68. package/scripts/workflow-sentinel.js +793 -0
  69. package/src/api/server.js +297 -38
  70. /package/scripts/{rlhf_session_start.sh → thumbgate_session_start.sh} +0 -0
package/src/api/server.js CHANGED
@@ -13,6 +13,10 @@ const {
13
13
  getFeedbackPaths,
14
14
  appendDiagnosticRecord,
15
15
  } = require('../../scripts/feedback-loop');
16
+ const {
17
+ readActiveProjectState,
18
+ resolveProjectDir,
19
+ } = require('../../scripts/feedback-paths');
16
20
  const {
17
21
  readRecentConversationWindow,
18
22
  } = require('../../scripts/feedback-history-distiller');
@@ -48,6 +52,11 @@ const {
48
52
  const {
49
53
  buildCloudflareSandboxPlan,
50
54
  } = require('../../scripts/cloudflare-dynamic-sandbox');
55
+ const {
56
+ listJobStates,
57
+ readJobState,
58
+ requestJobControl,
59
+ } = require('../../scripts/async-job-runner');
51
60
  const {
52
61
  loadModel,
53
62
  getReliability,
@@ -132,6 +141,13 @@ const {
132
141
  const {
133
142
  resolveAnalyticsWindow,
134
143
  } = require('../../scripts/analytics-window');
144
+ const {
145
+ launchDpoExportJob,
146
+ launchHarnessJob,
147
+ pauseQueuedJob,
148
+ cancelQueuedJob,
149
+ resumeHostedJob,
150
+ } = require('../../scripts/hosted-job-launcher');
135
151
  const {
136
152
  appendWorkflowSprintLead,
137
153
  advanceWorkflowSprintLead,
@@ -161,6 +177,9 @@ const SESSION_COOKIE_NAME = 'thumbgate_session_id';
161
177
  const ACQUISITION_COOKIE_NAME = 'thumbgate_acquisition_id';
162
178
  const VISITOR_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 90;
163
179
  const BUILD_METADATA = resolveBuildMetadata();
180
+ const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'cancelled']);
181
+ const IDLE_JOB_STATUSES = new Set(['queued', 'paused', 'resume_requested']);
182
+ const JOB_CONTROL_ACTIONS = new Set(['pause', 'cancel', 'resume']);
164
183
 
165
184
  // ---------------------------------------------------------------------------
166
185
  // Stripe event tracking helpers
@@ -203,8 +222,55 @@ function computeConversionStats(events) {
203
222
  }
204
223
  // ---------------------------------------------------------------------------
205
224
 
206
- function getSafeDataDir() {
207
- const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
225
+ function getRequestedProjectSelection(req, parsed) {
226
+ const projectFromQuery = parsed && parsed.searchParams
227
+ ? parsed.searchParams.get('project')
228
+ : null;
229
+ const projectFromHeaders = req && req.headers
230
+ ? req.headers['x-thumbgate-project-dir'] || req.headers['x-thumbgate-project']
231
+ : null;
232
+ return projectFromQuery || projectFromHeaders || null;
233
+ }
234
+
235
+ function getEffectiveRequestedProjectSelection(req, parsed) {
236
+ return isProjectSelectionAllowed(req, parsed)
237
+ ? getRequestedProjectSelection(req, parsed)
238
+ : null;
239
+ }
240
+
241
+ function isProjectSelectionAllowed(req, parsed) {
242
+ const explicitProject = getRequestedProjectSelection(req, parsed);
243
+ if (!explicitProject) return true;
244
+ return isLoopbackHost(getRequestHostHeader(req));
245
+ }
246
+
247
+ function resolveRequestProjectDir(req, parsed) {
248
+ const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
249
+ return resolveProjectDir({
250
+ projectDir: explicitProject,
251
+ env: process.env,
252
+ });
253
+ }
254
+
255
+ function shouldPreferProjectScopedFeedback(req, parsed) {
256
+ const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
257
+ if (explicitProject) return true;
258
+ if (process.env.THUMBGATE_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR) return true;
259
+ if (process.env.THUMBGATE_FEEDBACK_DIR) return false;
260
+ return Boolean(readActiveProjectState({ env: process.env }));
261
+ }
262
+
263
+ function getRequestFeedbackPaths(req, parsed) {
264
+ const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
265
+ return getFeedbackPaths({
266
+ projectDir: resolveRequestProjectDir(req, parsed),
267
+ explicitProjectDir: explicitProject,
268
+ skipExplicitFeedbackDir: shouldPreferProjectScopedFeedback(req, parsed),
269
+ });
270
+ }
271
+
272
+ function getSafeDataDir(req, parsed) {
273
+ const { FEEDBACK_LOG_PATH } = getRequestFeedbackPaths(req, parsed);
208
274
  return path.resolve(path.dirname(FEEDBACK_LOG_PATH));
209
275
  }
210
276
 
@@ -857,6 +923,14 @@ function getPublicOrigin(req) {
857
923
  return `${proto}://${host}`;
858
924
  }
859
925
 
926
+ function getRequestHostHeader(req) {
927
+ const forwardedHost = req.headers['x-forwarded-host'];
928
+ if (Array.isArray(forwardedHost)) {
929
+ return forwardedHost[0] || req.headers.host || '';
930
+ }
931
+ return forwardedHost || req.headers.host || '';
932
+ }
933
+
860
934
  function isLoopbackHost(hostValue) {
861
935
  const rawHost = String(hostValue || '').split(',')[0].trim();
862
936
  if (!rawHost) {
@@ -1156,6 +1230,7 @@ nav .container { display: flex; justify-content: space-between; align-items: cen
1156
1230
  .actions-bar { flex-direction: column; }
1157
1231
  }
1158
1232
  </style>
1233
+ <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
1159
1234
  </head>
1160
1235
  <body>
1161
1236
  <nav><div class="container">
@@ -1486,6 +1561,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
1486
1561
  font-size: 14px;
1487
1562
  }
1488
1563
  </style>
1564
+ <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
1489
1565
  </head>
1490
1566
  <body>
1491
1567
  <main>
@@ -1765,6 +1841,7 @@ function renderCheckoutCancelledPage(runtimeConfig) {
1765
1841
  margin-top: 12px;
1766
1842
  }
1767
1843
  </style>
1844
+ <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
1768
1845
  </head>
1769
1846
  <body>
1770
1847
  <main>
@@ -1937,6 +2014,7 @@ function renderWorkflowSprintIntakeResultPage(runtimeConfig, { title, detail, le
1937
2014
  border: 1px solid var(--line);
1938
2015
  }
1939
2016
  </style>
2017
+ <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
1940
2018
  </head>
1941
2019
  <body>
1942
2020
  <main>
@@ -2094,10 +2172,10 @@ function extractTags(input) {
2094
2172
  return [];
2095
2173
  }
2096
2174
 
2097
- function resolveSafePath(inputPath, { mustExist = false } = {}) {
2175
+ function resolveSafePath(inputPath, { mustExist = false, safeDataDir } = {}) {
2098
2176
  const allowExternal = process.env.THUMBGATE_ALLOW_EXTERNAL_PATHS === 'true';
2099
2177
  const resolved = path.resolve(String(inputPath || ''));
2100
- const SAFE_DATA_DIR = getSafeDataDir();
2178
+ const SAFE_DATA_DIR = safeDataDir || getSafeDataDir();
2101
2179
  const inSafeRoot = resolved === SAFE_DATA_DIR || resolved.startsWith(`${SAFE_DATA_DIR}${path.sep}`);
2102
2180
 
2103
2181
  if (!allowExternal && !inSafeRoot) {
@@ -2111,6 +2189,56 @@ function resolveSafePath(inputPath, { mustExist = false } = {}) {
2111
2189
  return resolved;
2112
2190
  }
2113
2191
 
2192
+ function resolveDpoExportPaths(body = {}, options = {}) {
2193
+ const { safeDataDir, fallbackMemoryLogPath = null } = options;
2194
+ return {
2195
+ inputPath: body.inputPath
2196
+ ? resolveSafePath(body.inputPath, { mustExist: true, safeDataDir })
2197
+ : null,
2198
+ memoryLogPath: body.memoryLogPath
2199
+ ? resolveSafePath(body.memoryLogPath, { mustExist: true, safeDataDir })
2200
+ : null,
2201
+ outputPath: body.outputPath
2202
+ ? resolveSafePath(body.outputPath, { safeDataDir })
2203
+ : null,
2204
+ fallbackMemoryLogPath,
2205
+ };
2206
+ }
2207
+
2208
+ function loadDpoExportMemories({ inputPath, memoryLogPath, fallbackMemoryLogPath = null }) {
2209
+ if (inputPath) {
2210
+ const raw = fs.readFileSync(inputPath, 'utf-8');
2211
+ const parsedMemories = JSON.parse(raw);
2212
+ return Array.isArray(parsedMemories) ? parsedMemories : parsedMemories.memories || [];
2213
+ }
2214
+
2215
+ return readJSONL(memoryLogPath || fallbackMemoryLogPath || DEFAULT_LOCAL_MEMORY_LOG);
2216
+ }
2217
+
2218
+ function parseJobStatuses(value) {
2219
+ if (!value) return [];
2220
+ return String(value)
2221
+ .split(',')
2222
+ .map((entry) => entry.trim())
2223
+ .filter(Boolean);
2224
+ }
2225
+
2226
+ function readHostedJobOrThrow(jobId) {
2227
+ const state = readJobState(jobId);
2228
+ if (!state) {
2229
+ throw createHttpError(404, `Job not found: ${jobId}`);
2230
+ }
2231
+ return state;
2232
+ }
2233
+
2234
+ function normalizeJobIdFromPath(pathname, suffix = '') {
2235
+ const pattern = suffix
2236
+ ? new RegExp(`^/v1/jobs/([^/]+)${suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`)
2237
+ : /^\/v1\/jobs\/([^/]+)$/;
2238
+ const match = pathname.match(pattern);
2239
+ return match ? decodeURIComponent(match[1]) : null;
2240
+ }
2241
+
2114
2242
  function createApiServer() {
2115
2243
  const expectedApiKey = getExpectedApiKey();
2116
2244
 
@@ -2121,6 +2249,13 @@ function createApiServer() {
2121
2249
  const isGetLikeRequest = req.method === 'GET' || isHeadRequest;
2122
2250
  const publicOrigin = getPublicOrigin(req);
2123
2251
  const hostedConfig = resolveHostedBillingConfig({ requestOrigin: publicOrigin });
2252
+ if (!isProjectSelectionAllowed(req, parsed)) {
2253
+ sendJson(res, 403, { error: 'project selection is only available on localhost requests' });
2254
+ return;
2255
+ }
2256
+ const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
2257
+ const requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
2258
+ const requestSafeDataDir = getSafeDataDir(req, parsed);
2124
2259
 
2125
2260
  // Public MCP endpoint — responds to Smithery registry scanning and MCP initialize
2126
2261
  // The initialize handshake is unauthenticated; subsequent tool calls require Bearer auth
@@ -2394,7 +2529,7 @@ function createApiServer() {
2394
2529
  const signal = parsed.searchParams.get('signal');
2395
2530
  if (signal === 'up' || signal === 'down') {
2396
2531
  const chatHistory = readRecentConversationWindow({
2397
- feedbackDir: getSafeDataDir(),
2532
+ feedbackDir: requestSafeDataDir,
2398
2533
  limit: 10,
2399
2534
  });
2400
2535
  const result = captureFeedback({
@@ -2484,7 +2619,7 @@ async function addContext(){
2484
2619
  sendJson(res, 400, { error: 'relatedFeedbackId is required' });
2485
2620
  return;
2486
2621
  }
2487
- const feedbackDir = getSafeDataDir();
2622
+ const feedbackDir = requestSafeDataDir;
2488
2623
  const detailField = signal === 'down' ? 'whatWentWrong' : 'whatWorked';
2489
2624
  const updated = updateLessonRecord(feedbackDir, relatedFeedbackId, (existing) => {
2490
2625
  const nextTags = Array.from(new Set([
@@ -2536,7 +2671,7 @@ async function addContext(){
2536
2671
  const lessonUpdateMatch = pathname.match(/^\/lessons\/([^/]+)\/update$/);
2537
2672
  if (req.method === 'POST' && lessonUpdateMatch) {
2538
2673
  const lessonId = decodeURIComponent(lessonUpdateMatch[1]);
2539
- const feedbackDir = getSafeDataDir();
2674
+ const feedbackDir = requestSafeDataDir;
2540
2675
  const body = await parseJsonBody(req);
2541
2676
  const record = findRecordById(lessonId, feedbackDir);
2542
2677
  if (!record) {
@@ -2568,7 +2703,7 @@ async function addContext(){
2568
2703
  const lessonDeleteMatch = pathname.match(/^\/lessons\/([^/]+)\/delete$/);
2569
2704
  if (req.method === 'POST' && lessonDeleteMatch) {
2570
2705
  const lessonId = decodeURIComponent(lessonDeleteMatch[1]);
2571
- const feedbackDir = getSafeDataDir();
2706
+ const feedbackDir = requestSafeDataDir;
2572
2707
  const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
2573
2708
  const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
2574
2709
  const deletedMemory = deleteRecordFromJsonl(memoryLogPath, lessonId);
@@ -2585,7 +2720,7 @@ async function addContext(){
2585
2720
  const lessonDetailMatch = pathname.match(/^\/lessons\/([^/]+)$/);
2586
2721
  if (isGetLikeRequest && lessonDetailMatch && lessonDetailMatch[1] !== '') {
2587
2722
  const lessonId = decodeURIComponent(lessonDetailMatch[1]);
2588
- const feedbackDir = getSafeDataDir();
2723
+ const feedbackDir = requestSafeDataDir;
2589
2724
  const record = findRecordById(lessonId, feedbackDir);
2590
2725
  if (!record) {
2591
2726
  sendHtml(res, 404, renderLessonDetailHtml(null, lessonId));
@@ -2713,7 +2848,7 @@ async function addContext(){
2713
2848
  version: pkg.version,
2714
2849
  status: 'ok',
2715
2850
  docs: 'https://github.com/IgorGanapolsky/ThumbGate',
2716
- endpoints: ['/health', '/dashboard', '/guide', '/learn', '/pro', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/dashboard', '/v1/dashboard/render-spec', '/v1/settings/status', '/v1/dpo/export', '/v1/analytics/databricks/export'],
2851
+ endpoints: ['/health', '/dashboard', '/guide', '/learn', '/pro', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/dashboard', '/v1/dashboard/render-spec', '/v1/settings/status', '/v1/dpo/export', '/v1/jobs', '/v1/jobs/harness', '/v1/analytics/databricks/export'],
2717
2852
  }, {}, {
2718
2853
  headOnly: isHeadRequest,
2719
2854
  });
@@ -2959,7 +3094,7 @@ async function addContext(){
2959
3094
  }
2960
3095
 
2961
3096
  if (isGetLikeRequest && pathname === '/healthz') {
2962
- const { FEEDBACK_LOG_PATH, MEMORY_LOG_PATH } = getFeedbackPaths();
3097
+ const { FEEDBACK_LOG_PATH, MEMORY_LOG_PATH } = requestFeedbackPaths;
2963
3098
  sendJson(res, 200, {
2964
3099
  status: 'ok',
2965
3100
  feedbackLogPath: FEEDBACK_LOG_PATH,
@@ -3497,7 +3632,7 @@ async function addContext(){
3497
3632
 
3498
3633
  try {
3499
3634
  if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
3500
- sendJson(res, 200, analyzeFeedback());
3635
+ sendJson(res, 200, analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH));
3501
3636
  return;
3502
3637
  }
3503
3638
 
@@ -3649,6 +3784,112 @@ async function addContext(){
3649
3784
  return;
3650
3785
  }
3651
3786
 
3787
+ if (req.method === 'POST' && pathname === '/v1/jobs/harness') {
3788
+ const body = await parseJsonBody(req);
3789
+ const identifier = body.harness || body.harnessId;
3790
+ if (!identifier) {
3791
+ throw createHttpError(400, 'harness is required');
3792
+ }
3793
+ const inputs = parseOptionalObject(body.inputs, 'inputs') || {};
3794
+ try {
3795
+ const launched = launchHarnessJob(identifier, inputs, {
3796
+ jobId: normalizeNullableText(body.jobId) || undefined,
3797
+ skill: normalizeNullableText(body.skill) || undefined,
3798
+ partnerProfile: normalizeNullableText(body.partnerProfile) || undefined,
3799
+ autoImprove: body.autoImprove !== false,
3800
+ });
3801
+ sendJson(res, 202, {
3802
+ accepted: true,
3803
+ jobId: launched.jobId,
3804
+ status: launched.state.status,
3805
+ launchMode: launched.launchMode,
3806
+ pid: launched.pid,
3807
+ statusUrl: `/v1/jobs/${encodeURIComponent(launched.jobId)}`,
3808
+ job: launched.state,
3809
+ });
3810
+ } catch (err) {
3811
+ throw createHttpError(err.statusCode || 400, err.message || 'Invalid hosted harness request');
3812
+ }
3813
+ return;
3814
+ }
3815
+
3816
+ if (req.method === 'GET' && pathname === '/v1/jobs') {
3817
+ const limit = Number(parsed.searchParams.get('limit') || 20);
3818
+ const statuses = parseJobStatuses(parsed.searchParams.get('status'));
3819
+ const jobs = listJobStates({
3820
+ limit: Number.isFinite(limit) ? limit : 20,
3821
+ statuses,
3822
+ });
3823
+ sendJson(res, 200, {
3824
+ total: jobs.length,
3825
+ jobs,
3826
+ });
3827
+ return;
3828
+ }
3829
+
3830
+ {
3831
+ const jobId = normalizeJobIdFromPath(pathname);
3832
+ if (req.method === 'GET' && jobId) {
3833
+ sendJson(res, 200, {
3834
+ job: readHostedJobOrThrow(jobId),
3835
+ });
3836
+ return;
3837
+ }
3838
+ }
3839
+
3840
+ {
3841
+ const jobId = normalizeJobIdFromPath(pathname, '/control');
3842
+ if (req.method === 'POST' && jobId) {
3843
+ const state = readHostedJobOrThrow(jobId);
3844
+ const body = await parseJsonBody(req);
3845
+ const action = normalizeNullableText(body.action);
3846
+ if (!action || !JOB_CONTROL_ACTIONS.has(action)) {
3847
+ throw createHttpError(400, 'action must be one of pause, cancel, or resume');
3848
+ }
3849
+
3850
+ if (TERMINAL_JOB_STATUSES.has(state.status)) {
3851
+ throw createHttpError(409, `Job ${jobId} is already ${state.status}`);
3852
+ }
3853
+
3854
+ if (action === 'resume') {
3855
+ const launched = resumeHostedJob(jobId);
3856
+ sendJson(res, 202, {
3857
+ accepted: true,
3858
+ action,
3859
+ jobId,
3860
+ launchMode: launched.launchMode,
3861
+ pid: launched.pid,
3862
+ job: launched.state,
3863
+ });
3864
+ return;
3865
+ }
3866
+
3867
+ if (IDLE_JOB_STATUSES.has(state.status)) {
3868
+ const job = action === 'pause'
3869
+ ? pauseQueuedJob(jobId, parseOptionalObject(body.metadata, 'metadata') || {})
3870
+ : cancelQueuedJob(jobId, parseOptionalObject(body.metadata, 'metadata') || {});
3871
+ sendJson(res, 202, {
3872
+ accepted: true,
3873
+ action,
3874
+ jobId,
3875
+ job,
3876
+ });
3877
+ return;
3878
+ }
3879
+
3880
+ const metadata = parseOptionalObject(body.metadata, 'metadata') || {};
3881
+ const control = requestJobControl(jobId, action, metadata);
3882
+ sendJson(res, 202, {
3883
+ accepted: true,
3884
+ action,
3885
+ jobId,
3886
+ control,
3887
+ job: readHostedJobOrThrow(jobId),
3888
+ });
3889
+ return;
3890
+ }
3891
+ }
3892
+
3652
3893
  if (req.method === 'POST' && pathname === '/v1/gates/constraint') {
3653
3894
  const body = await parseJsonBody(req);
3654
3895
  if (!body.key || body.value === undefined) {
@@ -3740,7 +3981,9 @@ async function addContext(){
3740
3981
 
3741
3982
  if (req.method === 'GET' && pathname === '/v1/feedback/summary') {
3742
3983
  const recent = Number(parsed.searchParams.get('recent') || 20);
3743
- const summary = feedbackSummary(Number.isFinite(recent) ? recent : 20);
3984
+ const summary = feedbackSummary(Number.isFinite(recent) ? recent : 20, {
3985
+ feedbackDir: requestFeedbackDir,
3986
+ });
3744
3987
  sendJson(res, 200, { summary });
3745
3988
  return;
3746
3989
  }
@@ -3757,6 +4000,7 @@ async function addContext(){
3757
4000
  limit: Number.isFinite(limit) ? limit : 10,
3758
4001
  category,
3759
4002
  tags,
4003
+ feedbackDir: requestFeedbackDir,
3760
4004
  });
3761
4005
  sendJson(res, 200, results);
3762
4006
  return;
@@ -3846,7 +4090,9 @@ async function addContext(){
3846
4090
  if (req.method === 'POST' && pathname === '/v1/feedback/rules') {
3847
4091
  const body = await parseJsonBody(req);
3848
4092
  const minOccurrences = Number(body.minOccurrences || 2);
3849
- const outputPath = body.outputPath ? resolveSafePath(body.outputPath) : undefined;
4093
+ const outputPath = body.outputPath
4094
+ ? resolveSafePath(body.outputPath, { safeDataDir: requestSafeDataDir })
4095
+ : undefined;
3850
4096
  const result = writePreventionRules(outputPath, Number.isFinite(minOccurrences) ? minOccurrences : 2);
3851
4097
  sendJson(res, 200, {
3852
4098
  path: result.path,
@@ -3872,25 +4118,38 @@ async function addContext(){
3872
4118
 
3873
4119
  if (req.method === 'POST' && pathname === '/v1/dpo/export') {
3874
4120
  const body = await parseJsonBody(req);
3875
- let memories = [];
4121
+ const paths = resolveDpoExportPaths(body, {
4122
+ safeDataDir: requestSafeDataDir,
4123
+ fallbackMemoryLogPath: requestFeedbackPaths.MEMORY_LOG_PATH,
4124
+ });
4125
+ const wantsAsync = body.async === true || normalizeNullableText(body.mode) === 'async';
3876
4126
 
3877
- if (body.inputPath) {
3878
- const safeInputPath = resolveSafePath(body.inputPath, { mustExist: true });
3879
- const raw = fs.readFileSync(safeInputPath, 'utf-8');
3880
- const parsedMemories = JSON.parse(raw);
3881
- memories = Array.isArray(parsedMemories) ? parsedMemories : parsedMemories.memories || [];
3882
- } else {
3883
- const localPath = body.memoryLogPath
3884
- ? resolveSafePath(body.memoryLogPath, { mustExist: true })
3885
- : DEFAULT_LOCAL_MEMORY_LOG;
3886
- memories = readJSONL(localPath);
4127
+ if (wantsAsync) {
4128
+ try {
4129
+ const launched = launchDpoExportJob(paths, {
4130
+ jobId: normalizeNullableText(body.jobId) || undefined,
4131
+ });
4132
+ sendJson(res, 202, {
4133
+ accepted: true,
4134
+ async: true,
4135
+ jobId: launched.jobId,
4136
+ status: launched.state.status,
4137
+ outputPath: paths.outputPath,
4138
+ statusUrl: `/v1/jobs/${encodeURIComponent(launched.jobId)}`,
4139
+ launchMode: launched.launchMode,
4140
+ job: launched.state,
4141
+ });
4142
+ } catch (err) {
4143
+ throw createHttpError(err.statusCode || 400, err.message || 'Invalid DPO export request');
4144
+ }
4145
+ return;
3887
4146
  }
3888
4147
 
4148
+ const memories = loadDpoExportMemories(paths);
3889
4149
  const result = exportDpoFromMemories(memories);
3890
- if (body.outputPath) {
3891
- const safeOutputPath = resolveSafePath(body.outputPath);
3892
- fs.mkdirSync(path.dirname(safeOutputPath), { recursive: true });
3893
- fs.writeFileSync(safeOutputPath, result.jsonl);
4150
+ if (paths.outputPath) {
4151
+ fs.mkdirSync(path.dirname(paths.outputPath), { recursive: true });
4152
+ fs.writeFileSync(paths.outputPath, result.jsonl);
3894
4153
  }
3895
4154
 
3896
4155
  sendJson(res, 200, {
@@ -3899,14 +4158,16 @@ async function addContext(){
3899
4158
  learnings: result.learnings.length,
3900
4159
  unpairedErrors: result.unpairedErrors.length,
3901
4160
  unpairedLearnings: result.unpairedLearnings.length,
3902
- outputPath: body.outputPath ? resolveSafePath(body.outputPath) : null,
4161
+ outputPath: paths.outputPath,
3903
4162
  });
3904
4163
  return;
3905
4164
  }
3906
4165
 
3907
4166
  if (req.method === 'POST' && pathname === '/v1/analytics/databricks/export') {
3908
4167
  const body = await parseJsonBody(req);
3909
- const outputPath = body.outputPath ? resolveSafePath(body.outputPath) : undefined;
4168
+ const outputPath = body.outputPath
4169
+ ? resolveSafePath(body.outputPath, { safeDataDir: requestSafeDataDir })
4170
+ : undefined;
3910
4171
  const result = exportDatabricksBundle(undefined, outputPath);
3911
4172
  sendJson(res, 200, result);
3912
4173
  return;
@@ -3971,7 +4232,7 @@ async function addContext(){
3971
4232
  // ----------------------------------------------------------------
3972
4233
 
3973
4234
  if (req.method === 'GET' && pathname === '/v1/quality/scores') {
3974
- const modelPath = path.join(getSafeDataDir(), 'feedback_model.json');
4235
+ const modelPath = path.join(requestSafeDataDir, 'feedback_model.json');
3975
4236
  const model = loadModel(modelPath);
3976
4237
  const reliability = getReliability(model);
3977
4238
  const category = parsed.searchParams.get('category');
@@ -3991,7 +4252,7 @@ async function addContext(){
3991
4252
  }
3992
4253
 
3993
4254
  if (req.method === 'GET' && pathname === '/v1/quality/rules') {
3994
- const rulesPath = path.join(getSafeDataDir(), 'prevention-rules.md');
4255
+ const rulesPath = path.join(requestSafeDataDir, 'prevention-rules.md');
3995
4256
  let markdown = '';
3996
4257
  if (fs.existsSync(rulesPath)) {
3997
4258
  markdown = fs.readFileSync(rulesPath, 'utf8').trim();
@@ -4008,7 +4269,7 @@ async function addContext(){
4008
4269
  }
4009
4270
 
4010
4271
  if (req.method === 'GET' && pathname === '/v1/quality/posteriors') {
4011
- const modelPath = path.join(getSafeDataDir(), 'feedback_model.json');
4272
+ const modelPath = path.join(requestSafeDataDir, 'feedback_model.json');
4012
4273
  const model = loadModel(modelPath);
4013
4274
  const posteriors = samplePosteriors(model);
4014
4275
  sendJson(res, 200, { posteriors });
@@ -4247,9 +4508,8 @@ async function addContext(){
4247
4508
  return;
4248
4509
  }
4249
4510
 
4250
- const { FEEDBACK_DIR } = getFeedbackPaths();
4251
4511
  const billingSummary = await getBillingSummaryLive(summaryOptions);
4252
- const data = generateDashboard(FEEDBACK_DIR, {
4512
+ const data = generateDashboard(requestFeedbackDir, {
4253
4513
  analyticsWindow: summaryOptions,
4254
4514
  billingSummary,
4255
4515
  billingSource: 'live',
@@ -4275,9 +4535,8 @@ async function addContext(){
4275
4535
  }
4276
4536
 
4277
4537
  try {
4278
- const { FEEDBACK_DIR } = getFeedbackPaths();
4279
4538
  const billingSummary = await getBillingSummaryLive(summaryOptions);
4280
- const data = generateDashboard(FEEDBACK_DIR, {
4539
+ const data = generateDashboard(requestFeedbackDir, {
4281
4540
  analyticsWindow: summaryOptions,
4282
4541
  billingSummary,
4283
4542
  billingSource: 'live',