@awak-app/simy-cli 0.5.66 → 0.5.68
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/package.json +1 -1
- package/src/agent.js +4 -0
- package/src/cli-contract.js +1 -1
- package/src/session-supervision/claude-sessions.js +25 -3
- package/src/session-supervision/index.js +6 -2
- package/src/session-supervision/session-titles.js +48 -0
- package/src/sqm/history-store.js +2 -2
- package/src/sqm/review-worker.js +72 -10
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -878,6 +878,10 @@ export async function startAgent({
|
|
|
878
878
|
return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, { beforeDispatch });
|
|
879
879
|
},
|
|
880
880
|
onChange: notifyDesktopStatusChange,
|
|
881
|
+
getWorkspaceUrl: () => {
|
|
882
|
+
const slug = desktopAccess.snapshot().organizationSlug;
|
|
883
|
+
return slug ? new URL(`/org/${encodeURIComponent(slug)}/quality-monitor/`, apiOrigin).toString() : null;
|
|
884
|
+
},
|
|
881
885
|
getHistoryScope: () => sqmHistoryScope(apiOrigin, session),
|
|
882
886
|
recordHistory: (scope, item) => sqmHistory.record(scope, item),
|
|
883
887
|
onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.68";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -3,6 +3,7 @@ import { lstat, open, readdir, realpath } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
|
|
6
|
+
import { sessionTitle } from './session-titles.js';
|
|
6
7
|
|
|
7
8
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
9
|
const MAX_PROJECTS = 256;
|
|
@@ -54,21 +55,41 @@ function metadataFromPrefix(bytes, id) {
|
|
|
54
55
|
const text = bytes.toString('utf8');
|
|
55
56
|
const complete = text.slice(0, text.lastIndexOf('\n') + 1);
|
|
56
57
|
if (!complete) return null;
|
|
57
|
-
let cwd = '', title = '', found = false;
|
|
58
|
+
let cwd = '', title = '', customTitle = '', found = false;
|
|
58
59
|
for (const line of complete.split('\n').slice(0, MAX_LINES)) {
|
|
59
60
|
if (!line) continue;
|
|
60
61
|
let item;
|
|
61
62
|
try { item = JSON.parse(line); } catch { return null; }
|
|
62
63
|
if (item.sessionId !== id) return null;
|
|
64
|
+
if (item.type === 'custom-title' && item.isSidechain !== true) { customTitle = sessionTitle(item.customTitle); continue; }
|
|
63
65
|
if (item.type !== 'user' && item.type !== 'assistant') continue;
|
|
64
66
|
if (item.isSidechain === true) return null;
|
|
65
67
|
if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd)) continue;
|
|
66
68
|
if (!cwd) cwd = resolve(item.cwd);
|
|
67
69
|
if (cwd !== resolve(item.cwd)) return null;
|
|
68
|
-
if (!title
|
|
70
|
+
if (!title) title = sessionTitle(item.slug);
|
|
69
71
|
found = true;
|
|
70
72
|
}
|
|
71
|
-
return found ? { cwd, title } : null;
|
|
73
|
+
return found ? { cwd, title: customTitle || title } : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// /rename appends a custom-title envelope, often beyond the bounded prefix.
|
|
77
|
+
// Inspect only those envelopes; conversation text is never used as a title.
|
|
78
|
+
async function latestCustomTitle(handle, size, id) {
|
|
79
|
+
const offset = Math.max(0, size - 64 * 1024);
|
|
80
|
+
const buffer = Buffer.alloc(Math.min(size, 64 * 1024));
|
|
81
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
82
|
+
let text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
83
|
+
if (offset) text = text.slice(text.indexOf('\n') + 1);
|
|
84
|
+
text = text.slice(0, text.lastIndexOf('\n') + 1);
|
|
85
|
+
let title = '';
|
|
86
|
+
for (const line of text.split('\n')) {
|
|
87
|
+
try {
|
|
88
|
+
const item = JSON.parse(line);
|
|
89
|
+
if (item.type === 'custom-title' && item.sessionId === id && item.isSidechain !== true) title = sessionTitle(item.customTitle);
|
|
90
|
+
} catch { /* Ignore partial or unrelated tail entries. */ }
|
|
91
|
+
}
|
|
92
|
+
return title;
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false) {
|
|
@@ -104,6 +125,7 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
|
|
|
104
125
|
const buffer = Buffer.allocUnsafe(Math.min(opened.size, MAX_PREFIX_BYTES));
|
|
105
126
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
106
127
|
const metadata = metadataFromPrefix(buffer.subarray(0, bytesRead), id);
|
|
128
|
+
if (metadata) metadata.title = await latestCustomTitle(handle, opened.size, id) || metadata.title;
|
|
107
129
|
if (cacheMetadata) {
|
|
108
130
|
const [after, pathAfter] = await Promise.all([handle.stat({ bigint: true }), lstat(path, { bigint: true })]);
|
|
109
131
|
if (fingerprint(after) === key && fingerprint(pathAfter) === key && pathAfter.isFile() && !pathAfter.isSymbolicLink())
|
|
@@ -4,6 +4,7 @@ import { TERMINAL as TERMINAL_RUNS, controlDelivery, listRecords, readRecord, pr
|
|
|
4
4
|
import { listCodexSessions, openCodexAppServer, readCodexSession, CodexAppServerError } from './codex-app-server.js';
|
|
5
5
|
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
6
6
|
import { originalSessionTarget } from './original-session-target.js';
|
|
7
|
+
import { readCodexTitles, sessionTitle } from './session-titles.js';
|
|
7
8
|
|
|
8
9
|
function autoContinueState(record, isRunning, supported) {
|
|
9
10
|
if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
|
|
@@ -55,7 +56,7 @@ async function liveClaudeHook(record, metadata, inspectProcess) {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
58
|
-
inspectProcess = processIdentity, now = Date.now(), signal } = {}) {
|
|
59
|
+
inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles } = {}) {
|
|
59
60
|
const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
|
|
60
61
|
assertActive();
|
|
61
62
|
const records = await listRecords(root);
|
|
@@ -81,6 +82,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
81
82
|
// Historical sessions must not fan out hundreds of process-inspection
|
|
82
83
|
// subprocesses. Preserve input ordering while limiting live metadata probes.
|
|
83
84
|
const items = [...byKey.values()], projected = new Array(items.length);
|
|
85
|
+
const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title)) ? await listCodexTitles() : new Map();
|
|
86
|
+
assertActive();
|
|
84
87
|
let nextItem = 0;
|
|
85
88
|
const project = async item => {
|
|
86
89
|
const record = recordsByKey.get(`${item.provider}:${item.session}`);
|
|
@@ -97,7 +100,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
97
100
|
else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
|
|
98
101
|
} catch { /* Unverified process leaves runtime status unknown. */ }
|
|
99
102
|
}
|
|
100
|
-
return { provider: item.provider, session: item.session, cwd: item.cwd,
|
|
103
|
+
return { provider: item.provider, session: item.session, cwd: item.cwd,
|
|
104
|
+
title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
|
|
101
105
|
...(typeof isRunning === 'boolean' ? { isRunning } : {}),
|
|
102
106
|
...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
|
|
103
107
|
...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
+
const MAX_INDEX_BYTES = 1024 * 1024;
|
|
8
|
+
let cached;
|
|
9
|
+
const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
|
|
10
|
+
|
|
11
|
+
export function sessionTitle(value) {
|
|
12
|
+
return typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) : '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Read title metadata only, including when an older CLI cannot list threads.
|
|
16
|
+
// This never reads prompts or changes the provider's continuation capabilities.
|
|
17
|
+
export async function readCodexTitles({ codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') } = {}) {
|
|
18
|
+
const path = join(codexHome, 'session_index.jsonl');
|
|
19
|
+
let handle;
|
|
20
|
+
try {
|
|
21
|
+
const before = await lstat(path, { bigint: true });
|
|
22
|
+
if (!before.isFile() || before.isSymbolicLink()) return new Map();
|
|
23
|
+
const key = fingerprint(before);
|
|
24
|
+
if (cached?.path === path && cached.key === key) return new Map(cached.titles);
|
|
25
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
26
|
+
if (fingerprint(await handle.stat({ bigint: true })) !== key) return new Map();
|
|
27
|
+
const size = Number(before.size), offset = Math.max(0, size - MAX_INDEX_BYTES);
|
|
28
|
+
const buffer = Buffer.alloc(Math.min(size, MAX_INDEX_BYTES));
|
|
29
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
30
|
+
let text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
31
|
+
if (offset) text = text.slice(text.indexOf('\n') + 1);
|
|
32
|
+
text = text.slice(0, text.lastIndexOf('\n') + 1);
|
|
33
|
+
const titles = new Map();
|
|
34
|
+
for (const line of text.split('\n')) {
|
|
35
|
+
try {
|
|
36
|
+
const item = JSON.parse(line);
|
|
37
|
+
if (!UUID.test(item.id || '')) continue;
|
|
38
|
+
const title = sessionTitle(item.thread_name);
|
|
39
|
+
// Appended renames win, including an explicitly cleared title.
|
|
40
|
+
if (typeof item.thread_name === 'string') titles.set(item.id, title);
|
|
41
|
+
} catch { /* A broken entry does not hide other sessions. */ }
|
|
42
|
+
}
|
|
43
|
+
if (fingerprint(await handle.stat({ bigint: true })) !== key || fingerprint(await lstat(path, { bigint: true })) !== key) return new Map();
|
|
44
|
+
cached = { path, key, titles };
|
|
45
|
+
return new Map(titles);
|
|
46
|
+
} catch { return new Map(); }
|
|
47
|
+
finally { await handle?.close(); }
|
|
48
|
+
}
|
package/src/sqm/history-store.js
CHANGED
|
@@ -60,7 +60,7 @@ export function createSqmHistoryStore({ root = defaultSessionRoot(), now = () =>
|
|
|
60
60
|
const item = (await this.items(scope)).find(item => item.key === key);
|
|
61
61
|
if (!item) return null;
|
|
62
62
|
return { key, instruction: item.instruction || `SQM local review · ${item.provider || "local"}`,
|
|
63
|
-
result: item.result || "", error: item.state === "interrupted" ? "This client stopped before the local run finished. Check the web workspace for its current retry status." : item.error || "", links: [] };
|
|
63
|
+
result: item.result || "", error: item.state === "interrupted" ? "This client stopped before the local run finished. Check the web workspace for its current retry status." : item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] };
|
|
64
64
|
},
|
|
65
65
|
};
|
|
66
66
|
}
|
|
@@ -69,7 +69,7 @@ function metadata(entry) {
|
|
|
69
69
|
if (!entry || !["analysis", "check"].includes(entry.kind) || typeof entry.key !== "string" || !entry.key.startsWith(entry.kind === "analysis" ? "sqm:" : "sqm-check:")) return null;
|
|
70
70
|
if (!["running", "completed", "failed", "unavailable", "interrupted", "verification_pending"].includes(entry.state) || !Number.isFinite(Date.parse(entry.startedAt))) return null;
|
|
71
71
|
const item = {};
|
|
72
|
-
for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error"]) {
|
|
72
|
+
for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "webUrl"]) {
|
|
73
73
|
if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
|
|
74
74
|
}
|
|
75
75
|
return item;
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -18,11 +18,12 @@ const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
|
|
|
18
18
|
export const SQM_CODEX_MODEL = "gpt-6.1-sol";
|
|
19
19
|
|
|
20
20
|
export class SqmLocalReviewError extends Error {
|
|
21
|
-
constructor(message, { status = null, code = "sqm_local_review_error" } = {}) {
|
|
21
|
+
constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false } = {}) {
|
|
22
22
|
super(message);
|
|
23
23
|
this.name = "SqmLocalReviewError";
|
|
24
24
|
this.status = status;
|
|
25
25
|
this.code = code;
|
|
26
|
+
this.stage = stage; this.jobId = jobId; this.failureRecorded = failureRecorded;
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -69,6 +70,7 @@ export function createSqmLocalReviewApi({
|
|
|
69
70
|
throw new SqmLocalReviewError(webApiErrorMessage(payload, response.status), {
|
|
70
71
|
status: response.status,
|
|
71
72
|
code: machineCode(payload?.code || payload?.detail?.code),
|
|
73
|
+
stage: payload?.detail?.stage, jobId: payload?.detail?.job_id, failureRecorded: payload?.detail?.failure_recorded === true,
|
|
72
74
|
});
|
|
73
75
|
}
|
|
74
76
|
return payload;
|
|
@@ -87,6 +89,7 @@ export function createSqmLocalReviewWorker({
|
|
|
87
89
|
onError = () => {},
|
|
88
90
|
onChange = () => {},
|
|
89
91
|
getHistoryScope = () => null,
|
|
92
|
+
getWorkspaceUrl = () => null,
|
|
90
93
|
recordHistory = async () => {},
|
|
91
94
|
onHistoryError = () => {},
|
|
92
95
|
} = {}) {
|
|
@@ -101,12 +104,13 @@ export function createSqmLocalReviewWorker({
|
|
|
101
104
|
const persist = async (scope, item) => {
|
|
102
105
|
try { await recordHistory(scope, item); } catch { onHistoryError(); /* History failure must not fail a leased review. */ }
|
|
103
106
|
};
|
|
104
|
-
const updateTask = async (id, state, scope) => {
|
|
107
|
+
const updateTask = async (id, state, scope, failure = null, failureReported = null) => {
|
|
105
108
|
const item = history.find(item => item.id === id);
|
|
106
109
|
if (item) {
|
|
107
110
|
item.state = state; item.finishedAt = new Date().toISOString();
|
|
108
111
|
item.result = state === "completed" ? "SQM review submitted." : "";
|
|
109
|
-
item.error = state === "failed" ?
|
|
112
|
+
item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
|
|
113
|
+
if (failure) { item.phase = failure.stage; item.errorCode = failure.code; }
|
|
110
114
|
await persist(scope, item);
|
|
111
115
|
}
|
|
112
116
|
onChange();
|
|
@@ -120,13 +124,12 @@ export function createSqmLocalReviewWorker({
|
|
|
120
124
|
lease_token: claim.lease_token,
|
|
121
125
|
});
|
|
122
126
|
|
|
123
|
-
const processClaim = async (claim, provider) => {
|
|
124
|
-
const historyScope = getHistoryScope();
|
|
127
|
+
const processClaim = async (claim, provider, historyScope, workspaceUrl) => {
|
|
125
128
|
activeJobId = claim.job_id;
|
|
126
129
|
const priorIndex = history.findIndex(item => item.id === claim.job_id);
|
|
127
130
|
if (priorIndex >= 0) history.splice(priorIndex, 1);
|
|
128
131
|
history.unshift({ id: claim.job_id, key: `sqm:${claim.job_id}`, label: `SQM · ${String(claim.job_id).slice(0, 8)}`,
|
|
129
|
-
kind: "analysis", state: "running", provider, startedAt: new Date().toISOString() });
|
|
132
|
+
kind: "analysis", state: "running", provider, startedAt: new Date().toISOString(), webUrl: analysisTaskUrl(workspaceUrl, claim.job_id) });
|
|
130
133
|
historyScopes.set(history[0], JSON.stringify(historyScope));
|
|
131
134
|
history.splice(50);
|
|
132
135
|
await persist(historyScope, history[0]);
|
|
@@ -137,8 +140,10 @@ export function createSqmLocalReviewWorker({
|
|
|
137
140
|
};
|
|
138
141
|
heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
|
|
139
142
|
heartbeatTimer.unref?.();
|
|
143
|
+
let stage = "analysis";
|
|
140
144
|
try {
|
|
141
145
|
const analysis = await runReview(claim.request, provider);
|
|
146
|
+
stage = "advance";
|
|
142
147
|
const advanced = await api.request({
|
|
143
148
|
action: "advance",
|
|
144
149
|
...leaseBody(claim, provider),
|
|
@@ -146,7 +151,9 @@ export function createSqmLocalReviewWorker({
|
|
|
146
151
|
executor_model: analysis.model,
|
|
147
152
|
});
|
|
148
153
|
if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
|
|
154
|
+
stage = "adjudication";
|
|
149
155
|
const adjudication = await runReview(advanced.request, provider);
|
|
156
|
+
stage = "complete";
|
|
150
157
|
const completed = await api.request({
|
|
151
158
|
action: "complete",
|
|
152
159
|
...leaseBody(claim, provider),
|
|
@@ -157,7 +164,16 @@ export function createSqmLocalReviewWorker({
|
|
|
157
164
|
await updateTask(claim.job_id, "completed", historyScope);
|
|
158
165
|
return completed;
|
|
159
166
|
} catch (error) {
|
|
160
|
-
|
|
167
|
+
const failure = reviewFailure(error, stage, provider);
|
|
168
|
+
let failureReported = error.failureRecorded === true;
|
|
169
|
+
if (!failureReported) {
|
|
170
|
+
try {
|
|
171
|
+
const report = await api.request({ action: "fail", ...leaseBody(claim, provider),
|
|
172
|
+
error_stage: failure.stage, error_code: failure.code, ...(failure.status ? { error_status: failure.status } : {}) });
|
|
173
|
+
failureReported = report?.recorded === true;
|
|
174
|
+
} catch { /* Keep the original failure even when reporting fails. */ }
|
|
175
|
+
}
|
|
176
|
+
await updateTask(claim.job_id, "failed", historyScope, failure, failureReported);
|
|
161
177
|
throw error;
|
|
162
178
|
} finally {
|
|
163
179
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
@@ -173,7 +189,7 @@ export function createSqmLocalReviewWorker({
|
|
|
173
189
|
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
|
|
174
190
|
desktopTaskDetail(key) {
|
|
175
191
|
const item = visibleHistory().find(item => item.key === key);
|
|
176
|
-
return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.
|
|
192
|
+
return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
|
|
177
193
|
},
|
|
178
194
|
resetHistory() { if (!pollPromise) { history.splice(0); issue = null; } },
|
|
179
195
|
poll() {
|
|
@@ -183,6 +199,8 @@ export function createSqmLocalReviewWorker({
|
|
|
183
199
|
assertProvider(provider);
|
|
184
200
|
const releaseWork = beginWork?.();
|
|
185
201
|
if (!releaseWork) return Promise.resolve(null);
|
|
202
|
+
const historyScope = getHistoryScope();
|
|
203
|
+
const workspaceUrl = getWorkspaceUrl();
|
|
186
204
|
pollPromise = (async () => {
|
|
187
205
|
const claim = await api.request({
|
|
188
206
|
action: "claim",
|
|
@@ -192,9 +210,18 @@ export function createSqmLocalReviewWorker({
|
|
|
192
210
|
});
|
|
193
211
|
if (issue) { issue = null; onChange(); }
|
|
194
212
|
if (!claim?.claimed) return null;
|
|
195
|
-
return processClaim(claim, provider);
|
|
213
|
+
return processClaim(claim, provider, historyScope, workspaceUrl);
|
|
196
214
|
})()
|
|
197
|
-
.catch((error) => {
|
|
215
|
+
.catch(async (error) => {
|
|
216
|
+
if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
|
|
217
|
+
const scope = historyScope;
|
|
218
|
+
const failure = reviewFailure(error, "claim", provider);
|
|
219
|
+
const item = { id: error.jobId, key: `sqm:${error.jobId}`, label: `SQM · ${error.jobId.slice(0, 8)}`,
|
|
220
|
+
kind: "analysis", state: "failed", provider, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(),
|
|
221
|
+
phase: failure.stage, errorCode: failure.code, error: formatFailure(failure, error.failureRecorded === true), webUrl: analysisTaskUrl(workspaceUrl, error.jobId) };
|
|
222
|
+
history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
|
|
223
|
+
await persist(scope, item);
|
|
224
|
+
}
|
|
198
225
|
issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
|
|
199
226
|
onError(error);
|
|
200
227
|
return null;
|
|
@@ -453,3 +480,38 @@ function assertProvider(provider) {
|
|
|
453
480
|
throw new TypeError("Unsupported SQM local review provider");
|
|
454
481
|
}
|
|
455
482
|
}
|
|
483
|
+
|
|
484
|
+
function reviewFailure(error, stage, provider) {
|
|
485
|
+
let code = machineCode(error?.code);
|
|
486
|
+
const message = errorMessage(error);
|
|
487
|
+
if (code === "sqm_local_review_error" || code === `${provider}_local_review_failed`) {
|
|
488
|
+
if (/timed out|timeout/i.test(message)) code = "analysis_timeout";
|
|
489
|
+
else if (providerSignInRequired(message)) code = "provider_sign_in_required";
|
|
490
|
+
else if (stage === "analysis" || stage === "adjudication") code = `${provider}_local_review_failed`;
|
|
491
|
+
}
|
|
492
|
+
return { stage: /^[a-z_]{1,40}$/.test(error?.stage || "") ? error.stage : stage, code,
|
|
493
|
+
status: Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : null };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function formatFailure(failure, failureReported = null) {
|
|
497
|
+
if (!failure) return "SQM analysis failed. No failure diagnostics were recorded.";
|
|
498
|
+
const hint = failure.code === "provider_sign_in_required" ? "Sign in to the local AI app again."
|
|
499
|
+
: failure.code === "analysis_timeout" ? "The local AI analysis exceeded its time limit."
|
|
500
|
+
: failure.code === "sqm_source_loading_failed" ? "SIMY could not load evidence for this analysis."
|
|
501
|
+
: failure.status === 403 || failure.code === "desktop_access_required" ? "Check SQM and Developer access for the selected organization."
|
|
502
|
+
: failure.status === 401 ? "Reconnect your SIMY session."
|
|
503
|
+
: failure.code.endsWith("_local_unavailable") ? "Install or update the selected local AI app."
|
|
504
|
+
: failure.code.endsWith("_local_review_failed") ? "The local AI app did not complete a valid structured review."
|
|
505
|
+
: "Open analysis details to inspect the task state.";
|
|
506
|
+
return `${failure.stage} · ${failure.code}${failure.status ? ` · HTTP ${failure.status}` : ""}\n${hint}${failureReported === false ? "\nThe failure could not be reported to SIMY Web. The web task may still show as analyzing until its lease expires." : ""}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function analysisTaskUrl(workspaceUrl, id) {
|
|
510
|
+
if (!workspaceUrl || !/^[a-f0-9-]{36}$/i.test(id)) return "";
|
|
511
|
+
try {
|
|
512
|
+
const url = new URL(workspaceUrl);
|
|
513
|
+
if (!/^https?:$/.test(url.protocol)) return "";
|
|
514
|
+
url.searchParams.set("qualityTab", "analysis"); url.searchParams.set("qualityAnalysis", id);
|
|
515
|
+
return url.toString();
|
|
516
|
+
} catch { return ""; }
|
|
517
|
+
}
|