toolroll 0.9.84 → 0.9.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +94 -1193
- package/dist/agent-fence.d.ts +3 -3
- package/dist/agent-fence.js +3 -3
- package/dist/agent-onboard.d.ts +2 -0
- package/dist/agent-onboard.js +3 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +31 -31
- package/dist/browser-workspace.d.ts +5 -0
- package/dist/chat-channel.d.ts +10 -1
- package/dist/chat-channel.js +35 -5
- package/dist/chat-decide.d.ts +16 -4
- package/dist/chat-decide.js +239 -31
- package/dist/chat-messages.d.ts +5 -0
- package/dist/chat-messages.js +1 -1
- package/dist/chat-quiet.js +3 -2
- package/dist/chat-review.d.ts +4 -0
- package/dist/chat-review.js +13 -4
- package/dist/chat-terms.d.ts +9 -0
- package/dist/chat-terms.js +46 -0
- package/dist/contracts/console-api.d.ts +2 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/lead-tools.d.ts +30 -0
- package/dist/contracts/lead-tools.js +3 -0
- package/dist/contracts/youiyoux-request.d.ts +248 -0
- package/dist/contracts/youiyoux-request.js +152 -0
- package/dist/flow-cadence.d.ts +8 -2
- package/dist/flow-cadence.js +16 -3
- package/dist/flow-card-task.d.ts +9 -0
- package/dist/flow-card-task.js +15 -6
- package/dist/integrations-ui.js +9 -4
- package/dist/integrations.d.ts +9 -1
- package/dist/integrations.js +43 -2
- package/dist/landed-cleanup.d.ts +4 -0
- package/dist/landed-cleanup.js +3 -2
- package/dist/lead-capabilities.js +2 -1
- package/dist/lead-cli.js +3 -1
- package/dist/lead-tools.d.ts +2 -0
- package/dist/lead-tools.js +30 -4
- package/dist/lead.d.ts +2 -0
- package/dist/lead.js +3 -3
- package/dist/operate.d.ts +1 -2
- package/dist/operate.js +11 -15
- package/dist/project-activity.d.ts +20 -0
- package/dist/project-activity.js +23 -0
- package/dist/project-cli.d.ts +1 -0
- package/dist/project-cli.js +36 -19
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/project-outcomes.d.ts +119 -0
- package/dist/project-outcomes.js +79 -0
- package/dist/result-actions.d.ts +4 -0
- package/dist/result-actions.js +41 -9
- package/dist/result-review.d.ts +9 -0
- package/dist/result-review.js +45 -5
- package/dist/server/console.css +12 -0
- package/dist/server/pages.js +11 -1
- package/dist/server/render-chat.js +3 -2
- package/dist/server/render-pages.d.ts +2 -2
- package/dist/server/render-pages.js +7 -2
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +20 -7
- package/dist/server/render-tasks.d.ts +1 -0
- package/dist/server/render-tasks.js +15 -5
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +18 -1
- package/dist/server/task-views.js +6 -0
- package/dist/server/tasks.js +42 -6
- package/dist/store.d.ts +46 -3
- package/dist/store.js +165 -6
- package/dist/task-source.d.ts +13 -1
- package/dist/task-source.js +17 -1
- package/dist/telegram-lead.js +7 -5
- package/dist/telegram.js +1 -1
- package/dist/toolroll-update.js +2 -2
- package/dist/webhooks.d.ts +6 -0
- package/dist/webhooks.js +25 -0
- package/dist/work-index.d.ts +4 -0
- package/dist/work-index.js +32 -0
- package/dist/youiyoux-intake.d.ts +234 -0
- package/dist/youiyoux-intake.js +908 -0
- package/package.json +11 -8
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const activityAge = (ms) => ms === null ? 'Unknown' : ms < 3600000 ? `${Math.floor(ms / 60000)}m` : ms < 86400000 ? `${(ms / 3600000).toFixed(1)}h` : `${(ms / 86400000).toFixed(1)}d`;
|
|
2
|
+
export const activityMoney = (usd) => usd === null ? 'Unknown' : `$${usd.toFixed(2)}`;
|
|
3
|
+
export const ACTIVITY_ACTORS = { human: 'Human', lead: 'Lead', landed: 'Automatically landed', 'machine-or-unknown': 'Machine or unknown' };
|
|
4
|
+
export const activityScope = (report) => `Current Ready backlog; completions in the last ${report.days} days. Completion does not mean checks passed or work deployed.`;
|
|
5
|
+
export const activityLimit = (report) => `Partial report: only the ${report.limit} most recently updated eligible families are included. Counts and coverage below describe this sample.`;
|
|
6
|
+
export function activityMetrics(report) {
|
|
7
|
+
const ready = report.families.filter(one => one.state === 'ready');
|
|
8
|
+
const complete = report.families.filter(one => one.state === 'completed');
|
|
9
|
+
const metrics = [
|
|
10
|
+
{ label: 'Ready', value: report.ready.count === 0 ? '0' : `${report.ready.count} · Oldest ${activityAge(report.ready.oldestFinishedAgeMs)}`,
|
|
11
|
+
detail: `Age is measured from the latest result finishing, a proxy for Ready entry. ${report.ready.dated}/${report.ready.count} have usable timestamps.`, rows: ready },
|
|
12
|
+
{ label: 'Finished to decision', value: report.completed.count === 0 ? 'No completed families' : `${activityAge(report.finishedToDecision.meanMs)} average`,
|
|
13
|
+
detail: `${report.finishedToDecision.samples}/${report.completed.count} completed families have usable timestamps. Elapsed time to the saved completion decision, not human work time.`, rows: complete },
|
|
14
|
+
{ label: 'Explicit revisions', value: report.revisions.perCompletedFamily === null ? 'No completed families' : `${report.revisions.perCompletedFamily.toFixed(1)} per completed family`,
|
|
15
|
+
detail: `${report.revisions.count} revisions across ${report.revisions.samples} completed families. Retries are attempts, not revisions.`, rows: complete },
|
|
16
|
+
{ label: 'Recorded spend', value: report.completed.count === 0 ? 'No completed families' : `${activityMoney(report.cost.recordedPerCompletedFamilyUsd)} per completed family · ${report.cost.knownRuns}/${report.cost.runs} costs known`,
|
|
17
|
+
detail: `${activityMoney(report.cost.recordedUsd)} in API charges across ${report.cost.families} completed families; ${report.cost.fullyCostedFamilies}/${report.cost.families} fully costed.${report.cost.subscriptionRuns > 0 ? ` ${report.cost.subscriptionRuns} subscription runs are plan usage, not charges${report.cost.subscriptionReportedUsd === null ? '' : ` (${activityMoney(report.cost.subscriptionReportedUsd)} reported)`}.` : ''} Missing or unreported costs are unknown. All family attempts count once, including planning and retries.`, rows: complete },
|
|
18
|
+
{ label: 'Completed families', value: String(report.completed.count),
|
|
19
|
+
detail: `${Object.entries(report.completed.actors).map(([key, count]) => `${ACTIVITY_ACTORS[key]}: ${count}`).join(' · ')}. Scouts: ${report.completed.scouts}. Failed checks: ${report.completed.failedChecks}.`, rows: complete },
|
|
20
|
+
...(report.incompleteHistory > 0 ? [{ label: 'Incomplete history', value: String(report.incompleteHistory), detail: 'Excluded from completion averages; their family relationships cannot be established.', rows: report.families.filter(one => one.state === 'history-unavailable') }] : []),
|
|
21
|
+
];
|
|
22
|
+
return report.completed.count === 0 ? metrics.filter(one => one.label === 'Ready' || one.label === 'Completed families' || one.label === 'Incomplete history') : metrics;
|
|
23
|
+
}
|
package/dist/project-cli.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export declare function runProjectCommand(positional: readonly string[], flags:
|
|
|
12
12
|
write: (line: string) => void;
|
|
13
13
|
json: boolean;
|
|
14
14
|
registryFile?: string;
|
|
15
|
+
now?: Date;
|
|
15
16
|
}): Promise<number>;
|
|
16
17
|
/** Only known credential-aware adapters consume the remembered reference.
|
|
17
18
|
* Explicit credentials always win; no legacy action gains implicit authority. */
|
package/dist/project-cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { configPath, loadRepos } from './repos.js';
|
|
|
8
8
|
import { authenticateCoordinator } from './coordinator.js';
|
|
9
9
|
import { canonicalProject } from './project.js';
|
|
10
10
|
import { envelopeJson } from './envelope.js';
|
|
11
|
+
import { projectOutcomes } from './project-outcomes.js';
|
|
11
12
|
export const projectProfilePath = (databaseFile) => join(dirname(databaseFile), 'cli-project.json');
|
|
12
13
|
export function readProjectProfile(databaseFile) {
|
|
13
14
|
const file = projectProfilePath(databaseFile);
|
|
@@ -25,11 +26,29 @@ export function readProjectProfile(databaseFile) {
|
|
|
25
26
|
throw new Error('The saved CLI project is invalid. Select it again with project use.');
|
|
26
27
|
return { version: 1, repo: p['repo'], tokenFile: p['tokenFile'] };
|
|
27
28
|
}
|
|
29
|
+
function scopedCredentialRefusal(store, tokenFile, repo) {
|
|
30
|
+
let token;
|
|
31
|
+
try {
|
|
32
|
+
const info = statSync(tokenFile);
|
|
33
|
+
if (!info.isFile() || info.size > 16_384)
|
|
34
|
+
return { reason: 'unauthenticated', message: 'The scoped credential file is unavailable.' };
|
|
35
|
+
token = readFileSync(tokenFile, 'utf8').trim();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { reason: 'unauthenticated', message: 'The scoped credential file is unavailable.' };
|
|
39
|
+
}
|
|
40
|
+
const auth = authenticateCoordinator(store, token);
|
|
41
|
+
if (!auth.ok)
|
|
42
|
+
return { reason: 'unauthenticated', message: 'The scoped credential is invalid or revoked.' };
|
|
43
|
+
if (!auth.who.repos.includes(repo))
|
|
44
|
+
return { reason: 'not-found', message: 'That project is unavailable to this credential.' };
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
28
47
|
export async function runProjectCommand(positional, flags, context) {
|
|
29
48
|
const action = positional[0] ?? 'show', command = `project ${action}`;
|
|
30
49
|
const fail = (reason, message) => { context.write(context.json ? envelopeJson({ ok: false, command, reason, message }) : message); return reason === 'usage' ? 2 : 3; };
|
|
31
|
-
if (!['use', 'show'].includes(action) || positional.length > (action === 'use' ? 2 : 1))
|
|
32
|
-
return fail('usage', 'Use project use <saved checkout path> [--token-file PATH], or project
|
|
50
|
+
if (!['use', 'show', 'activity'].includes(action) || positional.length > (action === 'use' ? 2 : 1))
|
|
51
|
+
return fail('usage', 'Use project use <saved checkout path> [--token-file PATH], project show, or project activity [--json].');
|
|
33
52
|
const allowed = new Set(['db', 'json', ...(action === 'use' ? ['token-file'] : [])]);
|
|
34
53
|
for (const name of flags.keys())
|
|
35
54
|
if (!allowed.has(name))
|
|
@@ -57,23 +76,9 @@ export async function runProjectCommand(positional, flags, context) {
|
|
|
57
76
|
if (credential !== undefined && (typeof credential !== 'string' || !credential || /[\x00-\x1f]/.test(credential)))
|
|
58
77
|
return fail('usage', '--token-file requires a path.');
|
|
59
78
|
const tokenFile = typeof credential === 'string' ? resolve(credential) : null;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const info = statSync(tokenFile);
|
|
64
|
-
if (!info.isFile() || info.size > 16_384)
|
|
65
|
-
return fail('unauthenticated', 'The scoped credential file is unavailable.');
|
|
66
|
-
token = readFileSync(tokenFile, 'utf8').trim();
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return fail('unauthenticated', 'The scoped credential file is unavailable.');
|
|
70
|
-
}
|
|
71
|
-
const auth = authenticateCoordinator(context.store, token);
|
|
72
|
-
if (!auth.ok)
|
|
73
|
-
return fail('unauthenticated', 'The scoped credential is invalid or revoked.');
|
|
74
|
-
if (!auth.who.repos.includes(repo))
|
|
75
|
-
return fail('not-found', 'That project is unavailable to this credential.');
|
|
76
|
-
}
|
|
79
|
+
const refused = tokenFile === null ? null : scopedCredentialRefusal(context.store, tokenFile, repo);
|
|
80
|
+
if (refused)
|
|
81
|
+
return fail(refused.reason, refused.message);
|
|
77
82
|
profile = { version: 1, repo, tokenFile };
|
|
78
83
|
const file = projectProfilePath(context.databaseFile), temp = `${file}.${randomBytes(6).toString('hex')}.tmp`;
|
|
79
84
|
mkdirSync(dirname(file), { recursive: true });
|
|
@@ -82,6 +87,18 @@ export async function runProjectCommand(positional, flags, context) {
|
|
|
82
87
|
}
|
|
83
88
|
else
|
|
84
89
|
profile = readProjectProfile(context.databaseFile);
|
|
90
|
+
if (action === 'activity') {
|
|
91
|
+
if (profile === null || !repos.includes(profile.repo))
|
|
92
|
+
return fail('not-found', 'Select a saved project with project use first.');
|
|
93
|
+
const refused = profile.tokenFile === null ? null : scopedCredentialRefusal(context.store, profile.tokenFile, profile.repo);
|
|
94
|
+
if (refused)
|
|
95
|
+
return fail(refused.reason, refused.message);
|
|
96
|
+
// Like assignment brief, an uncredentialed local database read has the
|
|
97
|
+
// local operator's access. A saved scoped credential is always re-proved.
|
|
98
|
+
const result = projectOutcomes(context.store, profile.repo, context.now ?? new Date(), { principal: 'operator', repos: [profile.repo] });
|
|
99
|
+
context.write(context.json ? envelopeJson({ ok: true, command, result }) : JSON.stringify(result, null, 2));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
85
102
|
const result = { profile, saved: profile !== null && repos.includes(profile.repo) };
|
|
86
103
|
context.write(context.json ? envelopeJson({ ok: true, command, result }) : profile === null ? 'No project selected. Use project use <saved checkout path>.' : `${profile.repo}${result.saved ? '' : ' (no longer saved)'}\n${profile.tokenFile === null ? 'Uses your existing local sign-in for operator actions.' : `Scoped credential reference: ${profile.tokenFile}`}`);
|
|
87
104
|
return 0;
|
|
@@ -23,17 +23,18 @@ export declare function knowledgeVersion(store: Store, repo: string, actor: stri
|
|
|
23
23
|
/** Chat reads only an admitted project. Keep the full source in the project UI. */
|
|
24
24
|
export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
|
|
25
25
|
title?: never;
|
|
26
|
+
content?: never;
|
|
26
27
|
revision: number;
|
|
27
28
|
stale: boolean;
|
|
28
29
|
notice: string;
|
|
29
30
|
decision?: never;
|
|
30
|
-
content?: never;
|
|
31
31
|
history?: never;
|
|
32
32
|
instructions?: never;
|
|
33
33
|
references?: never;
|
|
34
34
|
decisions?: never;
|
|
35
35
|
} | {
|
|
36
36
|
title?: never;
|
|
37
|
+
content?: never;
|
|
37
38
|
stale?: never;
|
|
38
39
|
decision: {
|
|
39
40
|
repo: string;
|
|
@@ -50,7 +51,6 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
50
51
|
id: number;
|
|
51
52
|
};
|
|
52
53
|
notice: string;
|
|
53
|
-
content?: never;
|
|
54
54
|
revision?: never;
|
|
55
55
|
history?: never;
|
|
56
56
|
instructions?: never;
|
|
@@ -69,9 +69,9 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
69
69
|
decisions?: never;
|
|
70
70
|
} | {
|
|
71
71
|
title?: never;
|
|
72
|
+
content?: never;
|
|
72
73
|
stale?: never;
|
|
73
74
|
decision?: never;
|
|
74
|
-
content?: never;
|
|
75
75
|
revision: number;
|
|
76
76
|
history: {
|
|
77
77
|
revision: number;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Store } from './store.js';
|
|
2
|
+
import type { WorkSummaryAccess } from './work-summary.js';
|
|
3
|
+
export declare const ACTIVITY_DAYS = 60;
|
|
4
|
+
export declare const ACTIVITY_LIMIT = 500;
|
|
5
|
+
export type CompletionActor = 'human' | 'lead' | 'landed' | 'machine-or-unknown';
|
|
6
|
+
export type ActivityFamily = {
|
|
7
|
+
root: string;
|
|
8
|
+
task: string;
|
|
9
|
+
title: string;
|
|
10
|
+
run: number | null;
|
|
11
|
+
href: string;
|
|
12
|
+
state: 'ready' | 'completed' | 'history-unavailable';
|
|
13
|
+
kind: string;
|
|
14
|
+
finishedAt: string | null;
|
|
15
|
+
completedAt: string | null;
|
|
16
|
+
actor: CompletionActor | null;
|
|
17
|
+
checks: string;
|
|
18
|
+
revisions: number | null;
|
|
19
|
+
elapsedMs: number | null;
|
|
20
|
+
runs: number;
|
|
21
|
+
/** Runs whose amount is known (reported-usage.ts runAmountOf): API dollars or subscription usage. */
|
|
22
|
+
knownCostRuns: number;
|
|
23
|
+
subscriptionRuns: number;
|
|
24
|
+
/** API dollars only; subscription usage is never added to them. */
|
|
25
|
+
recordedUsd: number | null;
|
|
26
|
+
subscriptionReportedUsd: number | null;
|
|
27
|
+
/** Every attempt billed in known API dollars; otherwise the family's dollar total is unknown. */
|
|
28
|
+
totalUsd: number | null;
|
|
29
|
+
};
|
|
30
|
+
export type ProjectOutcomes = {
|
|
31
|
+
asOf: string;
|
|
32
|
+
since: string;
|
|
33
|
+
days: number;
|
|
34
|
+
limit: number;
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
ready: {
|
|
37
|
+
count: number;
|
|
38
|
+
oldestFinishedAgeMs: number | null;
|
|
39
|
+
dated: number;
|
|
40
|
+
};
|
|
41
|
+
completed: {
|
|
42
|
+
count: number;
|
|
43
|
+
actors: Record<CompletionActor, number>;
|
|
44
|
+
scouts: number;
|
|
45
|
+
failedChecks: number;
|
|
46
|
+
};
|
|
47
|
+
finishedToDecision: {
|
|
48
|
+
meanMs: number | null;
|
|
49
|
+
samples: number;
|
|
50
|
+
};
|
|
51
|
+
revisions: {
|
|
52
|
+
count: number;
|
|
53
|
+
perCompletedFamily: number | null;
|
|
54
|
+
samples: number;
|
|
55
|
+
};
|
|
56
|
+
cost: {
|
|
57
|
+
recordedUsd: number | null;
|
|
58
|
+
recordedPerCompletedFamilyUsd: number | null;
|
|
59
|
+
runs: number;
|
|
60
|
+
knownRuns: number;
|
|
61
|
+
subscriptionRuns: number;
|
|
62
|
+
subscriptionReportedUsd: number | null;
|
|
63
|
+
fullyCostedFamilies: number;
|
|
64
|
+
families: number;
|
|
65
|
+
};
|
|
66
|
+
incompleteHistory: number;
|
|
67
|
+
families: ActivityFamily[];
|
|
68
|
+
};
|
|
69
|
+
/** Callers supply their current authenticated ceiling. A path alone grants no access. */
|
|
70
|
+
export declare function projectOutcomes(store: Store, project: string, now: Date, access: WorkSummaryAccess): ProjectOutcomes;
|
|
71
|
+
/** The lead's page of one report: every aggregate unchanged, family rows in pages bounded by count AND by their
|
|
72
|
+
* serialized size, so the reply always fits the tool-result cap (lead.ts, converse.ts LEAD_TOOL_RESULT_CAP_BYTES) and
|
|
73
|
+
* the lead reads further with nextOffset. Long ids and titles are shortened; an over-long link is left out. */
|
|
74
|
+
export declare const ACTIVITY_LEAD_PAGE = 10;
|
|
75
|
+
export declare const ACTIVITY_LEAD_PAGE_BYTES = 12000;
|
|
76
|
+
export declare function projectOutcomesPage(report: ProjectOutcomes, offset?: number): {
|
|
77
|
+
asOf: string;
|
|
78
|
+
since: string;
|
|
79
|
+
days: number;
|
|
80
|
+
limit: number;
|
|
81
|
+
truncated: boolean;
|
|
82
|
+
ready: {
|
|
83
|
+
count: number;
|
|
84
|
+
oldestFinishedAgeMs: number | null;
|
|
85
|
+
dated: number;
|
|
86
|
+
};
|
|
87
|
+
completed: {
|
|
88
|
+
count: number;
|
|
89
|
+
actors: Record<CompletionActor, number>;
|
|
90
|
+
scouts: number;
|
|
91
|
+
failedChecks: number;
|
|
92
|
+
};
|
|
93
|
+
finishedToDecision: {
|
|
94
|
+
meanMs: number | null;
|
|
95
|
+
samples: number;
|
|
96
|
+
};
|
|
97
|
+
revisions: {
|
|
98
|
+
count: number;
|
|
99
|
+
perCompletedFamily: number | null;
|
|
100
|
+
samples: number;
|
|
101
|
+
};
|
|
102
|
+
cost: {
|
|
103
|
+
recordedUsd: number | null;
|
|
104
|
+
recordedPerCompletedFamilyUsd: number | null;
|
|
105
|
+
runs: number;
|
|
106
|
+
knownRuns: number;
|
|
107
|
+
subscriptionRuns: number;
|
|
108
|
+
subscriptionReportedUsd: number | null;
|
|
109
|
+
fullyCostedFamilies: number;
|
|
110
|
+
families: number;
|
|
111
|
+
};
|
|
112
|
+
incompleteHistory: number;
|
|
113
|
+
families: (Omit<ActivityFamily, "href"> & {
|
|
114
|
+
href: string | null;
|
|
115
|
+
})[];
|
|
116
|
+
familiesTotal: number;
|
|
117
|
+
familiesOffset: number;
|
|
118
|
+
nextOffset: number | null;
|
|
119
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Read-only Activity. A completion is a saved decision, never a claim of
|
|
2
|
+
* passing checks, deployment, review labor, or improved productivity. */
|
|
3
|
+
import { chatResultHref } from './chat-controls.js';
|
|
4
|
+
import { workOutcomeRows } from './work-index.js';
|
|
5
|
+
import { COMPLETION_BY_PERSON, LANDED_ACTOR } from './result-completion.js';
|
|
6
|
+
export const ACTIVITY_DAYS = 60;
|
|
7
|
+
export const ACTIVITY_LIMIT = 500;
|
|
8
|
+
const text = (value) => value == null ? null : String(value);
|
|
9
|
+
const duration = (from, to) => {
|
|
10
|
+
const delta = from === null ? NaN : Date.parse(to) - Date.parse(from);
|
|
11
|
+
return Number.isFinite(delta) && delta >= 0 ? delta : null;
|
|
12
|
+
};
|
|
13
|
+
const actorOf = (actor, detail, byLead) => actor === LANDED_ACTOR ? 'landed'
|
|
14
|
+
: byLead || actor?.startsWith('lead:') ? 'lead'
|
|
15
|
+
: actor?.startsWith('operator:') && detail === COMPLETION_BY_PERSON ? 'human' : 'machine-or-unknown';
|
|
16
|
+
/** Callers supply their current authenticated ceiling. A path alone grants no access. */
|
|
17
|
+
export function projectOutcomes(store, project, now, access) {
|
|
18
|
+
if (access.repos !== null && !access.repos.includes(project))
|
|
19
|
+
throw new Error('Project activity is outside your access.');
|
|
20
|
+
const asOf = now.toISOString(), since = new Date(now.getTime() - ACTIVITY_DAYS * 86400000).toISOString();
|
|
21
|
+
const rows = workOutcomeRows(store, now, access, project, since, ACTIVITY_LIMIT);
|
|
22
|
+
const families = rows.slice(0, ACTIVITY_LIMIT).map(row => {
|
|
23
|
+
const state = row['code'] === 'complete' ? 'completed' : row['code'] === 'ready-to-check' ? 'ready' : 'history-unavailable';
|
|
24
|
+
const finishedAt = text(row['finished_at']), completedAt = text(row['checked_at']);
|
|
25
|
+
const runs = Number(row['runs']), apiRuns = Number(row['api_runs']), subscriptionRuns = Number(row['subscription_runs']);
|
|
26
|
+
const recordedUsd = apiRuns > 0 && Number.isFinite(Number(row['api_microusd'])) ? Number(row['api_microusd']) / 1_000_000 : null;
|
|
27
|
+
const subscriptionReportedUsd = row['subscription_usd'] != null && Number.isFinite(Number(row['subscription_usd'])) ? Number(row['subscription_usd']) : null;
|
|
28
|
+
const knownCostRuns = apiRuns + subscriptionRuns;
|
|
29
|
+
const task = String(row['task_id']);
|
|
30
|
+
return { root: String(row['root_id']), task, title: String(row['title']), run: row['result_id'] == null ? null : Number(row['result_id']),
|
|
31
|
+
href: row['result_id'] == null ? `/t/${encodeURIComponent(task)}` : chatResultHref(task, Number(row['result_id'])), state, kind: text(row['result_role']) ?? 'unknown',
|
|
32
|
+
finishedAt, completedAt, actor: state === 'completed' ? actorOf(text(row['checked_actor']), text(row['checked_detail']), Number(row['completed_by_lead']) === 1) : null,
|
|
33
|
+
checks: text(row['check_status']) ?? 'not-recorded', revisions: state === 'history-unavailable' ? null : Number(row['version_count']) - 1,
|
|
34
|
+
elapsedMs: duration(finishedAt, completedAt ?? asOf), runs, knownCostRuns, subscriptionRuns, recordedUsd, subscriptionReportedUsd,
|
|
35
|
+
totalUsd: runs > 0 && runs === apiRuns ? recordedUsd : null };
|
|
36
|
+
});
|
|
37
|
+
const ready = families.filter(one => one.state === 'ready'), completed = families.filter(one => one.state === 'completed');
|
|
38
|
+
const times = completed.flatMap(one => one.elapsedMs === null ? [] : [one.elapsedMs]);
|
|
39
|
+
const ages = ready.flatMap(one => one.elapsedMs === null ? [] : [one.elapsedMs]);
|
|
40
|
+
const actors = { human: 0, lead: 0, landed: 0, 'machine-or-unknown': 0 };
|
|
41
|
+
for (const one of completed)
|
|
42
|
+
actors[one.actor]++;
|
|
43
|
+
const knownRuns = completed.reduce((sum, one) => sum + one.knownCostRuns, 0);
|
|
44
|
+
const recordedUsd = completed.some(one => one.recordedUsd !== null) ? completed.reduce((sum, one) => sum + (one.recordedUsd ?? 0), 0) : null;
|
|
45
|
+
const subscriptionRuns = completed.reduce((sum, one) => sum + one.subscriptionRuns, 0);
|
|
46
|
+
const subscriptionReportedUsd = completed.some(one => one.subscriptionReportedUsd !== null) ? completed.reduce((sum, one) => sum + (one.subscriptionReportedUsd ?? 0), 0) : null;
|
|
47
|
+
const revisions = completed.reduce((sum, one) => sum + (one.revisions ?? 0), 0);
|
|
48
|
+
return { asOf, since, days: ACTIVITY_DAYS, limit: ACTIVITY_LIMIT, truncated: rows.length > ACTIVITY_LIMIT,
|
|
49
|
+
ready: { count: ready.length, oldestFinishedAgeMs: ages.length ? Math.max(...ages) : null, dated: ages.length },
|
|
50
|
+
completed: { count: completed.length, actors, scouts: completed.filter(one => one.kind === 'scout').length, failedChecks: completed.filter(one => one.checks === 'failed').length },
|
|
51
|
+
finishedToDecision: { meanMs: times.length ? times.reduce((a, b) => a + b, 0) / times.length : null, samples: times.length },
|
|
52
|
+
revisions: { count: revisions, perCompletedFamily: completed.length ? revisions / completed.length : null, samples: completed.length },
|
|
53
|
+
cost: { recordedUsd, recordedPerCompletedFamilyUsd: completed.length && recordedUsd !== null ? recordedUsd / completed.length : null,
|
|
54
|
+
runs: completed.reduce((sum, one) => sum + one.runs, 0), knownRuns, subscriptionRuns, subscriptionReportedUsd, fullyCostedFamilies: completed.filter(one => one.totalUsd !== null).length, families: completed.length },
|
|
55
|
+
incompleteHistory: families.filter(one => one.state === 'history-unavailable').length, families };
|
|
56
|
+
}
|
|
57
|
+
/** The lead's page of one report: every aggregate unchanged, family rows in pages bounded by count AND by their
|
|
58
|
+
* serialized size, so the reply always fits the tool-result cap (lead.ts, converse.ts LEAD_TOOL_RESULT_CAP_BYTES) and
|
|
59
|
+
* the lead reads further with nextOffset. Long ids and titles are shortened; an over-long link is left out. */
|
|
60
|
+
export const ACTIVITY_LEAD_PAGE = 10;
|
|
61
|
+
export const ACTIVITY_LEAD_PAGE_BYTES = 12_000;
|
|
62
|
+
const shorten = (value, max) => [...value].length > max ? `${[...value].slice(0, max - 1).join('')}…` : value;
|
|
63
|
+
const embeddedBytes = (value) => Buffer.byteLength(JSON.stringify(JSON.stringify(value)), 'utf8');
|
|
64
|
+
export function projectOutcomesPage(report, offset = 0) {
|
|
65
|
+
const start = Math.max(0, Math.min(Number.isSafeInteger(offset) ? offset : 0, report.families.length));
|
|
66
|
+
const page = [];
|
|
67
|
+
const base = { ...report, families: page, familiesTotal: report.families.length, familiesOffset: start, nextOffset: null };
|
|
68
|
+
for (const one of report.families.slice(start, start + ACTIVITY_LEAD_PAGE)) {
|
|
69
|
+
const row = { ...one, root: shorten(one.root, 200), task: shorten(one.task, 200), title: shorten(one.title, 80), href: Buffer.byteLength(one.href) > 512 ? null : one.href };
|
|
70
|
+
page.push(row);
|
|
71
|
+
if (page.length > 1 && embeddedBytes(base) > ACTIVITY_LEAD_PAGE_BYTES) {
|
|
72
|
+
page.pop();
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const next = start + page.length;
|
|
77
|
+
base.nextOffset = next < report.families.length ? next : null;
|
|
78
|
+
return base;
|
|
79
|
+
}
|
package/dist/result-actions.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
|
+
import { type FeedbackImage } from "./result-review.js";
|
|
2
3
|
export type RevisionResult = {
|
|
3
4
|
ok: true;
|
|
4
5
|
id: string;
|
|
@@ -8,6 +9,8 @@ export type RevisionResult = {
|
|
|
8
9
|
message: string;
|
|
9
10
|
};
|
|
10
11
|
type RevisionInput = Parameters<typeof createResultRevision>[2];
|
|
12
|
+
/** Re-prove image custody and current result at each write. No file paths supplied by callers. */
|
|
13
|
+
export declare function feedbackImageProblem(store: Store, root: string, runId: number, image: FeedbackImage | null | undefined): string | null;
|
|
11
14
|
/** One user action: add the typed change and seal exactly the displayed
|
|
12
15
|
* saved notes. A refusal rolls back the note; a replay returns the same task. */
|
|
13
16
|
export declare function requestResultChanges(store: Store, evidenceRoot: string, input: RevisionInput & {
|
|
@@ -15,6 +18,7 @@ export declare function requestResultChanges(store: Store, evidenceRoot: string,
|
|
|
15
18
|
path: string;
|
|
16
19
|
line: string;
|
|
17
20
|
request: string | null;
|
|
21
|
+
image?: FeedbackImage | null;
|
|
18
22
|
}, now: Date): RevisionResult;
|
|
19
23
|
export declare function createResultRevision(store: Store, evidenceRoot: string, input: {
|
|
20
24
|
run: number;
|
package/dist/result-actions.js
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
|
+
import { verifyResultImage } from "./chat-evidence.js";
|
|
1
2
|
/** Shared result-to-revision action for the result form and chat.
|
|
2
3
|
* The caller authenticates; this service checks project access and the exact
|
|
3
4
|
* displayed note batch, then uses the existing atomic revision/approval boundary.
|
|
4
5
|
*/
|
|
5
6
|
import { createHash, randomBytes } from "node:crypto";
|
|
6
7
|
import { EVIDENCE_CAPS, readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
|
|
7
|
-
import { isRevisionFeedback, parseRevisionBatch, revisionSourceOf, commentSourceKey, REQUEST_TOKEN } from "./result-review.js";
|
|
8
|
+
import { parseFeedbackImage, sameFeedbackImage, isRevisionFeedback, parseRevisionBatch, revisionSourceOf, commentSourceKey, REQUEST_TOKEN } from "./result-review.js";
|
|
8
9
|
import { validateNote, hasForbiddenControls } from "./decision.js";
|
|
9
10
|
import { TASK_TEXT_LIMITS, validateTaskText } from "./task-text.js";
|
|
10
11
|
import { modeFilingCoverage } from "./scope.js";
|
|
12
|
+
/** Re-prove image custody and current result at each write. No file paths supplied by callers. */
|
|
13
|
+
export function feedbackImageProblem(store, root, runId, image) {
|
|
14
|
+
if (image == null)
|
|
15
|
+
return null;
|
|
16
|
+
const parsed = parseFeedbackImage(image);
|
|
17
|
+
if (!parsed || image.run !== runId)
|
|
18
|
+
return "Choose a screenshot from this result. Your draft is unchanged.";
|
|
19
|
+
const run = store.getRun(runId);
|
|
20
|
+
const task = run === null ? null : store.externalIdFor(run.taskRef);
|
|
21
|
+
const repo = run === null ? null : store.refById(run.taskRef)?.repo;
|
|
22
|
+
if (!task || !repo)
|
|
23
|
+
return "This screenshot is no longer available. Your draft is unchanged.";
|
|
24
|
+
const family = store.taskFamilyOf(task, [repo], false);
|
|
25
|
+
const latest = store.runsFor(run.taskRef).find(one => ["builder", "repair", "scout"].includes(one.role) && one.outcome !== null);
|
|
26
|
+
if (family?.problem !== null || family.current.id !== task || latest?.id !== runId)
|
|
27
|
+
return "A newer result is current. Open it before commenting on a screenshot. Your draft is unchanged.";
|
|
28
|
+
const verified = verifyResultImage(store, root, [repo], { taskId: task, ...image });
|
|
29
|
+
return verified.ok ? null : "This screenshot is unavailable or changed. Restore it or remove it from your feedback. Your draft is unchanged.";
|
|
30
|
+
}
|
|
11
31
|
/** One user action: add the typed change and seal exactly the displayed
|
|
12
32
|
* saved notes. A refusal rolls back the note; a replay returns the same task. */
|
|
13
33
|
export function requestResultChanges(store, evidenceRoot, input, now) {
|
|
@@ -15,8 +35,8 @@ export function requestResultChanges(store, evidenceRoot, input, now) {
|
|
|
15
35
|
return store.transact(() => store.savepoint(() => {
|
|
16
36
|
const fail = (status, message) => { throw new ChangeRequestRefusal(status, message); };
|
|
17
37
|
if (!input.note.trim()) {
|
|
18
|
-
if (input.path.trim() || input.line.trim())
|
|
19
|
-
return fail(400, "Describe the change before attaching
|
|
38
|
+
if (input.path.trim() || input.line.trim() || input.image != null)
|
|
39
|
+
return fail(400, "Describe the change before attaching an image, file or line.");
|
|
20
40
|
if (!input.batch)
|
|
21
41
|
return fail(400, "Describe a change or use a saved note.");
|
|
22
42
|
return createResultRevision(store, evidenceRoot, input, now);
|
|
@@ -43,13 +63,19 @@ export function requestResultChanges(store, evidenceRoot, input, now) {
|
|
|
43
63
|
if (!artifact || !readVerifiedArtifact(evidenceRoot, artifact).ok)
|
|
44
64
|
return fail(409, "The saved changes could not be verified. Restore the result before requesting changes.");
|
|
45
65
|
const sourceKey = commentSourceKey(input.actor, input.request);
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
const earlier = store.diffCommentBySourceKey(sourceKey);
|
|
67
|
+
if (earlier && (earlier.run !== run.id || earlier.path !== path || earlier.line !== line || earlier.note !== valid.note || !sameFeedbackImage(earlier.image, input.image)))
|
|
68
|
+
return fail(409, "This submission already saved different feedback. Reopen the result and try again.");
|
|
69
|
+
// An exact consumed replay only returns its existing revision. Every new
|
|
70
|
+
// use proves the image before insertion, so malformed targets refuse cleanly.
|
|
71
|
+
if (earlier?.consumedBy == null) {
|
|
72
|
+
const problem = feedbackImageProblem(store, evidenceRoot, run.id, input.image);
|
|
73
|
+
if (problem !== null)
|
|
74
|
+
return fail(409, problem);
|
|
52
75
|
}
|
|
76
|
+
const noteId = earlier?.id ?? store.addDiffComment({ artifactId: artifact.id, runId: run.id, path, line, note: valid.note, author: input.actor, sourceKey, image: input.image ?? null }, now);
|
|
77
|
+
if (noteId === null)
|
|
78
|
+
return fail(409, "This feedback changed while saving. Your draft is unchanged; reopen the result.");
|
|
53
79
|
const result = createResultRevision(store, evidenceRoot, { ...input, batch: [...new Set([...batch, noteId])].sort((a, b) => a - b).join(",") }, now);
|
|
54
80
|
if (!result.ok)
|
|
55
81
|
return fail(result.status, result.message);
|
|
@@ -141,6 +167,11 @@ export function createResultRevision(store, evidenceRoot, input, now) {
|
|
|
141
167
|
}
|
|
142
168
|
if (batch.some(one => !isRevisionFeedback(one)))
|
|
143
169
|
return fail(409, "Review observations are not requested changes. Use Request change to add your feedback first.");
|
|
170
|
+
for (const one of batch) {
|
|
171
|
+
const problem = feedbackImageProblem(store, evidenceRoot, id, one.image);
|
|
172
|
+
if (problem !== null)
|
|
173
|
+
return fail(409, problem);
|
|
174
|
+
}
|
|
144
175
|
const comments = batch;
|
|
145
176
|
// Keep the subject recognizable, including when revising a revision.
|
|
146
177
|
// Reserve the suffix before truncating, at whole grapheme boundaries.
|
|
@@ -180,6 +211,7 @@ export function createResultRevision(store, evidenceRoot, input, now) {
|
|
|
180
211
|
note: one.note,
|
|
181
212
|
author: one.author,
|
|
182
213
|
createdAt: one.createdAt,
|
|
214
|
+
...(one.image == null ? {} : { image: one.image }),
|
|
183
215
|
})),
|
|
184
216
|
};
|
|
185
217
|
const briefBytes = Buffer.from(JSON.stringify(brief, null, 2), "utf8");
|
package/dist/result-review.d.ts
CHANGED
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* selected tab, and the reading position in this tab's sessionStorage,
|
|
20
20
|
* keyed by the server-named account, the task, and the run. */
|
|
21
21
|
import { type ResultFacts, type PublicationFacts, type DisplayStatus } from "./workspace-ui.js";
|
|
22
|
+
/** Optional context for feedback. The sealed diff remains the revision authority. */
|
|
23
|
+
export type FeedbackImage = {
|
|
24
|
+
run: number;
|
|
25
|
+
artifact: number;
|
|
26
|
+
sha256: string;
|
|
27
|
+
};
|
|
28
|
+
export declare function parseFeedbackImage(value: unknown): FeedbackImage | null | false;
|
|
29
|
+
export declare function sameFeedbackImage(a: FeedbackImage | null | undefined, b: FeedbackImage | null | undefined): boolean;
|
|
22
30
|
/** Add re-read evidence health to the existing result/review projection.
|
|
23
31
|
* This is presentation only: the recorded verdict and acceptance stay intact. */
|
|
24
32
|
export declare function evidenceResultStatusOf(result: ResultFacts, publication: PublicationFacts, evidence: EvidenceHealth): DisplayStatus;
|
|
@@ -49,6 +57,7 @@ export type ResultScreenshot = {
|
|
|
49
57
|
path: string;
|
|
50
58
|
caption: string;
|
|
51
59
|
artifactId: number;
|
|
60
|
+
sha256?: string;
|
|
52
61
|
problem: string | null;
|
|
53
62
|
};
|
|
54
63
|
export type ResultEvidenceInput = {
|
package/dist/result-review.js
CHANGED
|
@@ -19,6 +19,26 @@
|
|
|
19
19
|
* selected tab, and the reading position in this tab's sessionStorage,
|
|
20
20
|
* keyed by the server-named account, the task, and the run. */
|
|
21
21
|
import { resultStatusOf } from "./workspace-ui.js";
|
|
22
|
+
export function parseFeedbackImage(value) {
|
|
23
|
+
if (value === undefined || value === null || value === "")
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const image = typeof value === "string" ? JSON.parse(value) : value;
|
|
27
|
+
if (image === null || typeof image !== "object" || Array.isArray(image)
|
|
28
|
+
|| !Number.isSafeInteger(image.run) || image.run < 1
|
|
29
|
+
|| !Number.isSafeInteger(image.artifact) || image.artifact < 1
|
|
30
|
+
|| typeof image.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(image.sha256)
|
|
31
|
+
|| Object.keys(image).some(key => !["run", "artifact", "sha256"].includes(key)))
|
|
32
|
+
return false;
|
|
33
|
+
return { run: image.run, artifact: image.artifact, sha256: image.sha256 };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function sameFeedbackImage(a, b) {
|
|
40
|
+
return (a == null && b == null) || (a != null && b != null && a.run === b.run && a.artifact === b.artifact && a.sha256 === b.sha256);
|
|
41
|
+
}
|
|
22
42
|
/** Add re-read evidence health to the existing result/review projection.
|
|
23
43
|
* This is presentation only: the recorded verdict and acceptance stay intact. */
|
|
24
44
|
export function evidenceResultStatusOf(result, publication, evidence) {
|
|
@@ -155,6 +175,8 @@ export function resultFactsFromHtml(html) {
|
|
|
155
175
|
* page. Matched exactly — never a bare open redirect. */
|
|
156
176
|
export function resultReturnTarget(raw, runId, tasks = []) {
|
|
157
177
|
if (raw !== null && raw !== undefined) {
|
|
178
|
+
if (raw === `/r/${runId}?record=1`)
|
|
179
|
+
return raw;
|
|
158
180
|
const review = /^\/review\?result=[A-Za-z0-9._~%-]{1,200}(?:&run=([0-9]{1,15}))?(?:&tab=(?:summary|changes|checks))?$/.exec(raw);
|
|
159
181
|
if (review !== null && (review[1] === undefined || Number(review[1]) === runId))
|
|
160
182
|
return raw;
|
|
@@ -299,6 +321,22 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
|
|
|
299
321
|
document.addEventListener('click',function(ev){var link=ev.target&&ev.target.closest?ev.target.closest('a[href="#request-changes"]'):null;if(link)openForm();});
|
|
300
322
|
if(form){
|
|
301
323
|
var noteBox=form.querySelector('[name=note]'),pathBox=form.querySelector('[name=path]'),lineBox=form.querySelector('[name=line]'),pin=form.querySelector('details.result-pin');
|
|
324
|
+
var imageBox=form.querySelector('[name=image]'),imagePreview=form.querySelector('[data-feedback-image]');
|
|
325
|
+
function showImage(){
|
|
326
|
+
if(!imageBox||!imagePreview)return;
|
|
327
|
+
imagePreview.hidden=!imageBox.value;
|
|
328
|
+
var img=imagePreview.querySelector('img'),caption=imagePreview.querySelector('figcaption'),selected=null;
|
|
329
|
+
document.querySelectorAll('[data-comment-image]').forEach(function(button){if(button.getAttribute('data-comment-image')===imageBox.value)selected=button;});
|
|
330
|
+
img.hidden=!selected;
|
|
331
|
+
if(selected){var identity=JSON.parse(imageBox.value);img.src='/r/'+identity.run+'/evidence/'+identity.artifact;img.alt=selected.getAttribute('data-image-caption');caption.textContent=img.alt+' · Screenshot '+identity.artifact;}
|
|
332
|
+
else{img.removeAttribute('src');caption.textContent='This screenshot is unavailable. Restore it or remove it from your feedback.';}
|
|
333
|
+
}
|
|
334
|
+
document.addEventListener('click',function(event){
|
|
335
|
+
var button=event.target&&event.target.closest?event.target.closest('[data-comment-image],[data-remove-image]'):null;
|
|
336
|
+
if(!button||!imageBox)return;
|
|
337
|
+
event.preventDefault();imageBox.value=button.getAttribute('data-comment-image')||'';showImage();openForm();save();
|
|
338
|
+
if(noteBox){noteBox.dispatchEvent(new Event('input',{bubbles:true}));noteBox.scrollIntoView({block:'center',behavior:'instant'});noteBox.focus();}
|
|
339
|
+
});
|
|
302
340
|
var limit=document.getElementById('comment-note-limit'),requestBox=form.querySelector('[name=request]');
|
|
303
341
|
function updateActions(){
|
|
304
342
|
var typed=!!(noteBox&¬eBox.value.trim()),batch=form.querySelector('[name=batch]');
|
|
@@ -333,7 +371,7 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
|
|
|
333
371
|
// A fresh request identity: 16 random bytes as hex, the server's own shape.
|
|
334
372
|
function mint(){var bytes=new Uint8Array(16);try{crypto.getRandomValues(bytes);}catch(e){for(var i=0;i<16;i++)bytes[i]=Math.floor(Math.random()*256);}
|
|
335
373
|
var hex='';for(var j=0;j<16;j++)hex+=(bytes[j]<16?'0':'')+bytes[j].toString(16);return hex;}
|
|
336
|
-
function payload(){return JSON.stringify([noteBox?noteBox.value:'',pathBox?pathBox.value:'',lineBox?lineBox.value:'']);}
|
|
374
|
+
function payload(){return JSON.stringify([noteBox?noteBox.value:'',pathBox?pathBox.value:'',lineBox?lineBox.value:'',imageBox?imageBox.value:'']);}
|
|
337
375
|
// What the current token was last submitted with; null = never sent.
|
|
338
376
|
var sent=null,bound=null;
|
|
339
377
|
// The server refused this token for a different note or another result:
|
|
@@ -347,7 +385,7 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
|
|
|
347
385
|
var focusNote=function(){setTimeout(function(){noteBox.focus({preventScroll:true});},0);};
|
|
348
386
|
if(document.readyState==='complete')focusNote();else window.addEventListener('load',focusNote);
|
|
349
387
|
}
|
|
350
|
-
if(saved&&typeof saved.note==='string'&&saved.note.length<=
|
|
388
|
+
if(saved&&typeof saved.note==='string'&&saved.note.length<=(noteBox?noteBox.maxLength:4000)){
|
|
351
389
|
if(noteBox&&!noteBox.value)noteBox.value=saved.note;
|
|
352
390
|
if(saved.note)openForm();
|
|
353
391
|
if(pathBox&&!pathBox.value&&typeof saved.path==='string')pathBox.value=saved.path;
|
|
@@ -355,7 +393,9 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
|
|
|
355
393
|
if(pin&&(pathBox&&pathBox.value||lineBox&&lineBox.value))pin.open=true;
|
|
356
394
|
if(typeof saved.request==='string'&&/^[a-f0-9]{32}$/.test(saved.request)&&requestBox)requestBox.value=saved.request;
|
|
357
395
|
if(typeof saved.sent==='string')sent=saved.sent;
|
|
358
|
-
|
|
396
|
+
if(imageBox&&typeof saved.image==='string'&&saved.image.length<250)imageBox.value=saved.image;
|
|
397
|
+
showImage();
|
|
398
|
+
bound=JSON.stringify([saved.note,saved.path||'',saved.line||'',saved.image||'']);
|
|
359
399
|
if(noteBox)noteBox.dispatchEvent(new Event('input'));
|
|
360
400
|
}
|
|
361
401
|
function save(){
|
|
@@ -365,8 +405,8 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
|
|
|
365
405
|
var current=payload();
|
|
366
406
|
if((bound!==null&¤t!==bound)||(sent!==null&¤t!==sent)){sent=null;if(requestBox)requestBox.value=mint();}
|
|
367
407
|
bound=current;
|
|
368
|
-
if(!note&&!path&&!line){write(draftKey,null);return;}
|
|
369
|
-
write(draftKey,{note:note,path:path,line:line,request:requestBox?requestBox.value:'',sent:sent,at:Date.now()});
|
|
408
|
+
if(!note&&!path&&!line&&!(imageBox&&imageBox.value)){write(draftKey,null);return;}
|
|
409
|
+
write(draftKey,{note:note,path:path,line:line,image:imageBox?imageBox.value:'',request:requestBox?requestBox.value:'',sent:sent,at:Date.now()});
|
|
370
410
|
if(!storage&&limit)limit.textContent='Draft stays on this page only. Browser storage is unavailable.';
|
|
371
411
|
}
|
|
372
412
|
form.addEventListener('input',save);
|
package/dist/server/console.css
CHANGED
|
@@ -2730,3 +2730,15 @@ html.so-swap::view-transition-old(*):only-child { animation: so-card-out 160ms e
|
|
|
2730
2730
|
html.so-swap::view-transition-group(*), html.so-swap::view-transition-new(*), html.so-swap::view-transition-old(*) { animation: none !important; }
|
|
2731
2731
|
.worker.flash { animation: none; }
|
|
2732
2732
|
}
|
|
2733
|
+
|
|
2734
|
+
/* Exact-image feedback shares the existing comment form. */
|
|
2735
|
+
.result-image { margin: 0; min-width: 0; }
|
|
2736
|
+
.result-image > button { margin-top: .5rem; min-height: 44px; white-space: nowrap; }
|
|
2737
|
+
.feedback-image { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; margin: .5rem 0; min-width: 0; }
|
|
2738
|
+
.feedback-image[hidden], .feedback-image img[hidden] { display: none; }
|
|
2739
|
+
.feedback-image img { width: 5rem; max-height: 5rem; object-fit: contain; border: 1px solid var(--border); border-radius: var(--radius); }
|
|
2740
|
+
.feedback-image figcaption { flex: 1 1 12rem; overflow-wrap: anywhere; font-size: .875rem; }
|
|
2741
|
+
.feedback-image button { min-height: 44px; white-space: nowrap; }
|
|
2742
|
+
|
|
2743
|
+
.result-feedback-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
|
2744
|
+
.result-feedback-actions button { min-height: 44px; white-space: nowrap; }
|
package/dist/server/pages.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { projectOutcomes } from '../project-outcomes.js';
|
|
1
2
|
import { steerControlsFor } from "../steer-controls.js";
|
|
2
3
|
import { canReveal, cdCommandOf, localCheckoutOf } from "../checkout-proof.js";
|
|
3
4
|
import { instanceHttpAnswer } from "../console-instance.js";
|
|
@@ -967,7 +968,16 @@ export function createPagesHandlers(runtime) {
|
|
|
967
968
|
peeks[one.path] = peekOf(one.path);
|
|
968
969
|
for (const path of candidates)
|
|
969
970
|
peeks[path] = peekOf(path);
|
|
970
|
-
|
|
971
|
+
let activity;
|
|
972
|
+
if (open !== null && !returnTo.startsWith("/tasks/new") && visible(open) && store.accountCanAccess(who.name, open)) {
|
|
973
|
+
try {
|
|
974
|
+
activity = { project: projectName(open), report: projectOutcomes(store, open, now, { principal: 'operator', repos: [open] }) };
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
activity = { project: projectName(open), report: null };
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined, activity));
|
|
971
981
|
}
|
|
972
982
|
async function instanceEdge(ctx) {
|
|
973
983
|
const { request, response, url } = ctx;
|