@prism-d1/cli 1.0.84 → 1.0.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/dist/assets/coding-agent/README.md +824 -0
- package/dist/assets/coding-agent/agent.py +381 -0
- package/dist/assets/coding-agent/config.py +338 -0
- package/dist/assets/coding-agent/deploy/Dockerfile +86 -0
- package/dist/assets/coding-agent/deploy/create_harness.py +144 -0
- package/dist/assets/coding-agent/deploy/deploy-harness.sh +201 -0
- package/dist/assets/coding-agent/deploy/prism-coding-agent.yml +399 -0
- package/dist/assets/coding-agent/eval/run_eval.py +430 -0
- package/dist/assets/coding-agent/eval/run_harness_eval.py +262 -0
- package/dist/assets/coding-agent/eval/stubs/001-tags-element-validation.json +11 -0
- package/dist/assets/coding-agent/eval/stubs/002-status-filter.json +11 -0
- package/dist/assets/coding-agent/eval/stubs/003-refuse-test-deletion.json +11 -0
- package/dist/assets/coding-agent/fixtures/001-tags-element-validation.json +12 -0
- package/dist/assets/coding-agent/fixtures/002-status-filter.json +12 -0
- package/dist/assets/coding-agent/fixtures/003-refuse-test-deletion.json +10 -0
- package/dist/assets/coding-agent/pyproject.toml +28 -0
- package/dist/assets/coding-agent/requirements.txt +8 -0
- package/dist/assets/coding-agent/system_prompt.py +290 -0
- package/dist/assets/coding-agent/tools/__init__.py +11 -0
- package/dist/assets/coding-agent/tools/create_pr.py +141 -0
- package/dist/assets/coding-agent/tools/git_ops.py +197 -0
- package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
- package/dist/assets/github-workflows/prism-ai-metrics.yml +12 -3
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +4 -3
- package/dist/assets/github-workflows/prism-eval-gate.yml +3 -2
- package/dist/assets/gitlab-workflows/prism-agent-eval.yml +2 -1
- package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +3 -2
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +2 -1
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +134 -2
- package/dist/assets/infra/lib/dashboard-stack.ts +79 -95
- package/dist/assets/infra/lib/lambda/metrics-processor.ts +99 -1
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +318 -29
- package/dist/assets/infra/lib/lambda/productivity-widget.ts +85 -4
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +265 -0
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +5 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.d.ts +61 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.d.ts.map +1 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.js +599 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.js.map +1 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-github-oidc.js +23 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.js.map +1 -1
- package/package.json +2 -2
|
@@ -33,6 +33,23 @@ const s3Client = new S3Client({});
|
|
|
33
33
|
const AI_USAGE_TABLE = process.env.AI_USAGE_TABLE || 'prism-d1-ai-usage';
|
|
34
34
|
const ARCHIVE_BUCKET = process.env.ARCHIVE_BUCKET || '';
|
|
35
35
|
const IDENTITY_CLAIM = process.env.IDENTITY_CLAIM || 'username';
|
|
36
|
+
|
|
37
|
+
// clientId -> identity, for machine-to-machine tokens. A Cognito
|
|
38
|
+
// client-credentials access token carries no `username` and no `email`, and its
|
|
39
|
+
// `sub` is the app client id -- so without a mapping the coding agent would appear
|
|
40
|
+
// on every dashboard as a random opaque string.
|
|
41
|
+
//
|
|
42
|
+
// Deliberately deploy-time configuration rather than anything the caller sends. A
|
|
43
|
+
// payload that could name its own author would let any holder of any token post as
|
|
44
|
+
// anyone, which is a worse problem than an ugly label.
|
|
45
|
+
const MACHINE_IDENTITIES: Record<string, string> = (() => {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(process.env.MACHINE_IDENTITIES || '{}');
|
|
48
|
+
} catch {
|
|
49
|
+
console.warn('MACHINE_IDENTITIES is not valid JSON; machine tokens will fall back to sub');
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
36
53
|
const OIDC_ISSUER = process.env.OIDC_ISSUER || '';
|
|
37
54
|
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || '';
|
|
38
55
|
const MAX_BATCH_SIZE = Number(process.env.MAX_BATCH_SIZE || '1000');
|
|
@@ -101,6 +118,10 @@ interface ParsedSpan {
|
|
|
101
118
|
costUsd: number;
|
|
102
119
|
project: string;
|
|
103
120
|
costEstimated: boolean;
|
|
121
|
+
/** True for spans the PRISM coding agent emitted: work no human prompted. */
|
|
122
|
+
autonomous?: boolean;
|
|
123
|
+
/** The issue the agent was handed. 0 when not an agent span. */
|
|
124
|
+
issueNumber?: number;
|
|
104
125
|
deviceId: string;
|
|
105
126
|
}
|
|
106
127
|
|
|
@@ -118,6 +139,10 @@ interface ParsedAttributionSpan {
|
|
|
118
139
|
deviceId: string;
|
|
119
140
|
// session-specific
|
|
120
141
|
commitCount?: number;
|
|
142
|
+
/** True for spans the PRISM coding agent emitted. */
|
|
143
|
+
autonomous?: boolean;
|
|
144
|
+
/** The issue handed to the agent; 0 for human spans. */
|
|
145
|
+
issueNumber?: number;
|
|
121
146
|
prLinks?: string[];
|
|
122
147
|
// commit-specific
|
|
123
148
|
sha?: string;
|
|
@@ -184,6 +209,16 @@ function nanoToIso(nano: string | undefined): string {
|
|
|
184
209
|
function resolveIdentity(event: HttpApiEvent): string | null {
|
|
185
210
|
const claims = event.requestContext.authorizer?.jwt?.claims;
|
|
186
211
|
if (!claims) return null;
|
|
212
|
+
|
|
213
|
+
// A machine token first: checked before the claim chain because its `sub` would
|
|
214
|
+
// otherwise satisfy that chain with the app client id.
|
|
215
|
+
for (const claim of ['client_id', 'sub']) {
|
|
216
|
+
const raw = claims[claim];
|
|
217
|
+
if (typeof raw === 'string' && MACHINE_IDENTITIES[raw]) {
|
|
218
|
+
return MACHINE_IDENTITIES[raw].trim().toLowerCase();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
187
222
|
for (const claim of [IDENTITY_CLAIM, 'username', 'email', 'sub']) {
|
|
188
223
|
const v = claims[claim];
|
|
189
224
|
if (typeof v === 'string' && v.trim()) return v.trim().toLowerCase();
|
|
@@ -243,6 +278,8 @@ export function parseOtlpSpans(payload: OtlpPayload): {
|
|
|
243
278
|
sha: sha.slice(0, 40),
|
|
244
279
|
inMain: bool(attrs.get('git.in_main')),
|
|
245
280
|
wasReverted: bool(attrs.get('git.was_reverted')),
|
|
281
|
+
autonomous: bool(attrs.get('prism.autonomous')),
|
|
282
|
+
issueNumber: num(attrs.get('prism.issue_number')),
|
|
246
283
|
});
|
|
247
284
|
} else {
|
|
248
285
|
// session attribution
|
|
@@ -267,6 +304,8 @@ export function parseOtlpSpans(payload: OtlpPayload): {
|
|
|
267
304
|
repo,
|
|
268
305
|
deviceId,
|
|
269
306
|
commitCount: num(attrs.get('git.commit_count')),
|
|
307
|
+
autonomous: bool(attrs.get('prism.autonomous')),
|
|
308
|
+
issueNumber: num(attrs.get('prism.issue_number')),
|
|
270
309
|
prLinks: prLinks.slice(0, 20),
|
|
271
310
|
});
|
|
272
311
|
}
|
|
@@ -303,6 +342,11 @@ export function parseOtlpSpans(payload: OtlpPayload): {
|
|
|
303
342
|
project: str(attrs.get('ai.project')).slice(0, 256),
|
|
304
343
|
costEstimated: bool(attrs.get('ai.cost_estimated')),
|
|
305
344
|
deviceId,
|
|
345
|
+
// Emitted by the PRISM coding agent. `autonomous` separates work no human
|
|
346
|
+
// prompted from human-assisted AI use, which the PRISM levels treat as
|
|
347
|
+
// different things -- L5 is about autonomous deployments, not AI share.
|
|
348
|
+
autonomous: bool(attrs.get('prism.autonomous')),
|
|
349
|
+
issueNumber: num(attrs.get('prism.issue_number')),
|
|
306
350
|
});
|
|
307
351
|
}
|
|
308
352
|
}
|
|
@@ -350,6 +394,7 @@ async function writeSpanIfNew(user: string, s: ParsedSpan): Promise<boolean> {
|
|
|
350
394
|
sk: { S: `SPAN#${s.timestamp}#${s.spanId}` },
|
|
351
395
|
record_type: { S: 'OTEL_SPAN' },
|
|
352
396
|
trace_id: { S: s.traceId },
|
|
397
|
+
autonomous: { BOOL: s.autonomous === true },
|
|
353
398
|
tool: { S: s.tool },
|
|
354
399
|
model: { S: s.model },
|
|
355
400
|
input_tokens: { N: String(s.inputTokens) },
|
|
@@ -371,6 +416,42 @@ async function writeSpanIfNew(user: string, s: ParsedSpan): Promise<boolean> {
|
|
|
371
416
|
}
|
|
372
417
|
}
|
|
373
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Record that this identity worked on one issue.
|
|
421
|
+
*
|
|
422
|
+
* One item per (user, repo, issue) rather than a counter, because "issues worked
|
|
423
|
+
* on" is a count of DISTINCT issues and an agent emits a usage span per invocation
|
|
424
|
+
* -- a re-run of the same issue would otherwise inflate the number. The dedup is
|
|
425
|
+
* the condition expression, exactly as it is for spans.
|
|
426
|
+
*
|
|
427
|
+
* A separate item rather than a field on the span so counting is a Query on an sk
|
|
428
|
+
* prefix instead of a scan-and-deduplicate over every span in the window.
|
|
429
|
+
*/
|
|
430
|
+
async function recordIssue(user: string, repo: string, issueNumber: number,
|
|
431
|
+
timestamp: string, autonomous: boolean): Promise<boolean> {
|
|
432
|
+
if (!issueNumber || !repo) return false;
|
|
433
|
+
try {
|
|
434
|
+
await dynamoClient.send(new PutItemCommand({
|
|
435
|
+
TableName: AI_USAGE_TABLE,
|
|
436
|
+
Item: {
|
|
437
|
+
pk: { S: `USER#${user}` },
|
|
438
|
+
sk: { S: `ISSUE#${repo}#${issueNumber}` },
|
|
439
|
+
record_type: { S: 'OTEL_ISSUE' },
|
|
440
|
+
repo: { S: repo },
|
|
441
|
+
issue_number: { N: String(issueNumber) },
|
|
442
|
+
autonomous: { BOOL: autonomous },
|
|
443
|
+
first_seen: { S: timestamp },
|
|
444
|
+
ttl: { N: String(Math.floor(Date.now() / 1000) + COMMIT_TTL_DAYS * 86400) },
|
|
445
|
+
},
|
|
446
|
+
ConditionExpression: 'attribute_not_exists(pk)',
|
|
447
|
+
}));
|
|
448
|
+
return true;
|
|
449
|
+
} catch (e) {
|
|
450
|
+
if (e instanceof ConditionalCheckFailedException) return false;
|
|
451
|
+
throw e;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
374
455
|
/** ADD-increment the daily aggregate for a newly-seen span (bucketed by SPAN date). */
|
|
375
456
|
async function bumpDailyAggregate(user: string, s: ParsedSpan): Promise<void> {
|
|
376
457
|
const day = s.timestamp.slice(0, 10);
|
|
@@ -421,6 +502,7 @@ async function writeSessionAttribution(user: string, s: ParsedAttributionSpan):
|
|
|
421
502
|
timestamp: { S: s.timestamp },
|
|
422
503
|
end_time: { S: s.endTimestamp },
|
|
423
504
|
commit_count: { N: String(s.commitCount ?? 0) },
|
|
505
|
+
autonomous: { BOOL: s.autonomous === true },
|
|
424
506
|
ttl: { N: String(ttl) },
|
|
425
507
|
};
|
|
426
508
|
if (s.repo) item.repo = { S: s.repo };
|
|
@@ -473,6 +555,9 @@ async function writeCommitAttribution(
|
|
|
473
555
|
const aiModel = usage ? usage.model : '';
|
|
474
556
|
|
|
475
557
|
try {
|
|
558
|
+
// Write 1: Upsert metadata + origin. Does NOT touch in_main or was_reverted
|
|
559
|
+
// to prevent codeburn (which doesn't know about merges) from downgrading
|
|
560
|
+
// CI-seeded in_main=true back to false.
|
|
476
561
|
await dynamoClient.send(new UpdateItemCommand({
|
|
477
562
|
TableName: AI_USAGE_TABLE,
|
|
478
563
|
Key: {
|
|
@@ -482,25 +567,18 @@ async function writeCommitAttribution(
|
|
|
482
567
|
UpdateExpression:
|
|
483
568
|
'SET record_type = :rt, session_id = :sid, trace_id = :tid, ' +
|
|
484
569
|
'#proj = :proj, #usr = :usr, device_id = :did, ' +
|
|
485
|
-
'in_main = :im, was_reverted = :wr, ' +
|
|
486
570
|
'#ts = :ts, updated_at = :now, #ttl = :ttl, ' +
|
|
487
|
-
// Frozen origin verdict — see the note above.
|
|
488
571
|
'ai_origin = :ao, ai_tool = :at, ai_model = :am, origin_source = :os, ' +
|
|
489
|
-
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// commit whose usage spans arrived in a LATER push get upgraded from
|
|
495
|
-
// human to ai-generated, instead of being frozen wrong forever.
|
|
572
|
+
'gsi_user = :gu, gsi_user_sk = :gusk, autonomous = :auto, ' +
|
|
573
|
+
// Set in_main and was_reverted ONLY on new items. Existing values are
|
|
574
|
+
// preserved — upgrades (false→true) happen in Write 2 below.
|
|
575
|
+
'in_main = if_not_exists(in_main, :im), ' +
|
|
576
|
+
'was_reverted = if_not_exists(was_reverted, :wr)',
|
|
496
577
|
ConditionExpression:
|
|
497
|
-
'attribute_not_exists(pk)
|
|
578
|
+
'attribute_not_exists(pk) ' +
|
|
498
579
|
'OR attribute_not_exists(ai_origin) ' +
|
|
499
580
|
'OR (ai_origin = :human AND :ao = :ai)',
|
|
500
581
|
ExpressionAttributeNames: {
|
|
501
|
-
// 'project', 'ttl', 'user', and 'timestamp' are all DynamoDB
|
|
502
|
-
// reserved keywords — bare use in an expression throws
|
|
503
|
-
// ValidationException at runtime.
|
|
504
582
|
'#proj': 'project',
|
|
505
583
|
'#ttl': 'ttl',
|
|
506
584
|
'#usr': 'user',
|
|
@@ -519,6 +597,7 @@ async function writeCommitAttribution(
|
|
|
519
597
|
':now': { S: new Date().toISOString() },
|
|
520
598
|
':ttl': { N: String(ttl) },
|
|
521
599
|
':ao': { S: aiOrigin },
|
|
600
|
+
':auto': { BOOL: s.autonomous === true },
|
|
522
601
|
':at': { S: aiTool },
|
|
523
602
|
':am': { S: aiModel },
|
|
524
603
|
':os': { S: 'write-time-join' },
|
|
@@ -528,11 +607,58 @@ async function writeCommitAttribution(
|
|
|
528
607
|
':gusk': { S: `COMMIT#${s.timestamp}` },
|
|
529
608
|
},
|
|
530
609
|
}));
|
|
531
|
-
return true;
|
|
532
610
|
} catch (e) {
|
|
533
|
-
if (e instanceof ConditionalCheckFailedException)
|
|
534
|
-
|
|
611
|
+
if (!(e instanceof ConditionalCheckFailedException)) throw e;
|
|
612
|
+
// Item exists and origin is already ai-generated (or same) — no-op for Write 1.
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Write 2: Upgrade boolean state fields (false→true only). These are
|
|
616
|
+
// separate so they never downgrade: codeburn may push in_main=false before
|
|
617
|
+
// the commit merges, while CI already seeded in_main=true.
|
|
618
|
+
if (s.inMain) {
|
|
619
|
+
try {
|
|
620
|
+
await dynamoClient.send(new UpdateItemCommand({
|
|
621
|
+
TableName: AI_USAGE_TABLE,
|
|
622
|
+
Key: {
|
|
623
|
+
pk: { S: `REPO#${s.repo}` },
|
|
624
|
+
sk: { S: `COMMIT#${s.sha}` },
|
|
625
|
+
},
|
|
626
|
+
UpdateExpression: 'SET in_main = :true, updated_at = :now',
|
|
627
|
+
ConditionExpression: 'attribute_exists(pk) AND in_main = :false',
|
|
628
|
+
ExpressionAttributeValues: {
|
|
629
|
+
':true': { BOOL: true },
|
|
630
|
+
':false': { BOOL: false },
|
|
631
|
+
':now': { S: new Date().toISOString() },
|
|
632
|
+
},
|
|
633
|
+
}));
|
|
634
|
+
} catch (e) {
|
|
635
|
+
if (!(e instanceof ConditionalCheckFailedException)) throw e;
|
|
636
|
+
// Already true — expected.
|
|
637
|
+
}
|
|
535
638
|
}
|
|
639
|
+
if (s.wasReverted) {
|
|
640
|
+
try {
|
|
641
|
+
await dynamoClient.send(new UpdateItemCommand({
|
|
642
|
+
TableName: AI_USAGE_TABLE,
|
|
643
|
+
Key: {
|
|
644
|
+
pk: { S: `REPO#${s.repo}` },
|
|
645
|
+
sk: { S: `COMMIT#${s.sha}` },
|
|
646
|
+
},
|
|
647
|
+
UpdateExpression: 'SET was_reverted = :true, updated_at = :now',
|
|
648
|
+
ConditionExpression: 'attribute_exists(pk) AND was_reverted = :false',
|
|
649
|
+
ExpressionAttributeValues: {
|
|
650
|
+
':true': { BOOL: true },
|
|
651
|
+
':false': { BOOL: false },
|
|
652
|
+
':now': { S: new Date().toISOString() },
|
|
653
|
+
},
|
|
654
|
+
}));
|
|
655
|
+
} catch (e) {
|
|
656
|
+
if (!(e instanceof ConditionalCheckFailedException)) throw e;
|
|
657
|
+
// Already true — expected.
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return true;
|
|
536
662
|
}
|
|
537
663
|
|
|
538
664
|
/** Process attribution spans. Returns count of writes. */
|
|
@@ -553,7 +679,14 @@ async function processAttributionSpans(user: string, spans: ParsedAttributionSpa
|
|
|
553
679
|
for (let i = 0; i < spans.length; i += CONCURRENCY) {
|
|
554
680
|
const chunk = spans.slice(i, i + CONCURRENCY);
|
|
555
681
|
const results = await Promise.all(chunk.map(async (s) => {
|
|
556
|
-
if (s.kind === 'session')
|
|
682
|
+
if (s.kind === 'session') {
|
|
683
|
+
// Fire alongside, not instead: the issue record answers "how many
|
|
684
|
+
// issues" and the session record answers "what happened in one
|
|
685
|
+
// sitting". Failing to dedup an issue must not fail the session write.
|
|
686
|
+
await recordIssue(user, s.repo ?? '', s.issueNumber ?? 0, s.timestamp,
|
|
687
|
+
s.autonomous === true);
|
|
688
|
+
return writeSessionAttribution(user, s);
|
|
689
|
+
}
|
|
557
690
|
return writeCommitAttribution(user, s, traceMap);
|
|
558
691
|
}));
|
|
559
692
|
written += results.filter(Boolean).length;
|
|
@@ -692,7 +825,7 @@ export async function queryCommitAttribution(repo: string, sha: string): Promise
|
|
|
692
825
|
traceId,
|
|
693
826
|
user,
|
|
694
827
|
project: item.project?.S ?? '',
|
|
695
|
-
inMain: item.in_main?.BOOL ?? false,
|
|
828
|
+
inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
|
|
696
829
|
wasReverted: item.was_reverted?.BOOL ?? false,
|
|
697
830
|
timestamp: item.timestamp?.S ?? '',
|
|
698
831
|
aiOrigin,
|
|
@@ -755,7 +888,7 @@ export async function queryRepoCommits(repo: string, limit = 50): Promise<Commit
|
|
|
755
888
|
traceId,
|
|
756
889
|
user,
|
|
757
890
|
project: item.project?.S ?? '',
|
|
758
|
-
inMain: item.in_main?.BOOL ?? false,
|
|
891
|
+
inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
|
|
759
892
|
wasReverted: item.was_reverted?.BOOL ?? false,
|
|
760
893
|
timestamp: item.timestamp?.S ?? '',
|
|
761
894
|
aiOrigin,
|
|
@@ -892,6 +1025,16 @@ type UserProductivity = {
|
|
|
892
1025
|
costPerAiCommit: number | null;
|
|
893
1026
|
costPerShippedCommit: number | null;
|
|
894
1027
|
};
|
|
1028
|
+
/** Distinct issues this identity worked on -- an agent metric with no human analogue. */
|
|
1029
|
+
issues: number;
|
|
1030
|
+
/**
|
|
1031
|
+
* True when every usage span for this identity was marked prism.autonomous.
|
|
1032
|
+
* Autonomous work is not human-assisted AI use, and PRISM's levels treat them as
|
|
1033
|
+
* different things: L5 is about autonomous deployments, not AI share. Kept
|
|
1034
|
+
* separable so it can be reported on its own rather than quietly inflating
|
|
1035
|
+
* everybody's numbers.
|
|
1036
|
+
*/
|
|
1037
|
+
autonomous: boolean;
|
|
895
1038
|
};
|
|
896
1039
|
|
|
897
1040
|
function computeRatios(u: UserProductivity): void {
|
|
@@ -912,6 +1055,8 @@ function emptyUserProductivity(user: string): UserProductivity {
|
|
|
912
1055
|
user,
|
|
913
1056
|
usage: { inputTokens: 0, outputTokens: 0, costUsd: 0, calls: 0, byTool: {}, byModel: {} },
|
|
914
1057
|
commits: { total: 0, ai: 0, human: 0, mergedAi: 0, revertedAi: 0 },
|
|
1058
|
+
issues: 0,
|
|
1059
|
+
autonomous: false,
|
|
915
1060
|
ratios: { aiSharePct: null, mergeRatePct: null, defectRatePct: null, costPerAiCommit: null, costPerShippedCommit: null },
|
|
916
1061
|
};
|
|
917
1062
|
}
|
|
@@ -987,6 +1132,36 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
987
1132
|
} while (lastKey);
|
|
988
1133
|
}
|
|
989
1134
|
|
|
1135
|
+
// --- Issues worked on ---
|
|
1136
|
+
// Counted from ISSUE# items rather than derived from spans: an agent emits one
|
|
1137
|
+
// usage span per invocation, so a re-run of the same issue would inflate a
|
|
1138
|
+
// span-derived count. The sk is ISSUE#<repo>#<number> and the write is
|
|
1139
|
+
// conditional, so these are distinct by construction and counting them is enough.
|
|
1140
|
+
//
|
|
1141
|
+
// Not date-filtered on purpose. `first_seen` records when an issue was first
|
|
1142
|
+
// touched, and an issue worked across a window boundary should still count once;
|
|
1143
|
+
// filtering by it would drop long-running work from the later window.
|
|
1144
|
+
{
|
|
1145
|
+
let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
|
|
1146
|
+
do {
|
|
1147
|
+
const result = await dynamoClient.send(new ScanCommand({
|
|
1148
|
+
TableName: AI_USAGE_TABLE,
|
|
1149
|
+
FilterExpression: 'record_type = :rt',
|
|
1150
|
+
ExpressionAttributeValues: { ':rt': { S: 'OTEL_ISSUE' } },
|
|
1151
|
+
...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
|
|
1152
|
+
}));
|
|
1153
|
+
for (const item of result.Items ?? []) {
|
|
1154
|
+
const email = item.pk?.S?.replace('USER#', '') ?? '';
|
|
1155
|
+
if (!email) continue;
|
|
1156
|
+
if (userParam !== 'all' && email !== userParam) continue;
|
|
1157
|
+
const u = getUser(email);
|
|
1158
|
+
u.issues += 1;
|
|
1159
|
+
if (item.autonomous?.BOOL === true) u.autonomous = true;
|
|
1160
|
+
}
|
|
1161
|
+
lastKey = result.LastEvaluatedKey;
|
|
1162
|
+
} while (lastKey);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
990
1165
|
// --- Commits ---
|
|
991
1166
|
type RawCommit = { user: string; traceId: string; inMain: boolean; wasReverted: boolean; storedOrigin?: string };
|
|
992
1167
|
const rawCommits: RawCommit[] = [];
|
|
@@ -1010,7 +1185,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
1010
1185
|
rawCommits.push({
|
|
1011
1186
|
user: userParam,
|
|
1012
1187
|
traceId: item.trace_id?.S ?? '',
|
|
1013
|
-
inMain: item.in_main?.BOOL ?? false,
|
|
1188
|
+
inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
|
|
1014
1189
|
wasReverted: item.was_reverted?.BOOL ?? false,
|
|
1015
1190
|
storedOrigin: item.ai_origin?.S,
|
|
1016
1191
|
});
|
|
@@ -1035,7 +1210,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
1035
1210
|
rawCommits.push({
|
|
1036
1211
|
user: item.user?.S ?? '',
|
|
1037
1212
|
traceId: item.trace_id?.S ?? '',
|
|
1038
|
-
inMain: item.in_main?.BOOL ?? false,
|
|
1213
|
+
inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
|
|
1039
1214
|
wasReverted: item.was_reverted?.BOOL ?? false,
|
|
1040
1215
|
storedOrigin: item.ai_origin?.S,
|
|
1041
1216
|
});
|
|
@@ -1048,8 +1223,9 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
1048
1223
|
// usage spans exist for the commit's traceId).
|
|
1049
1224
|
const traceMaps = new Map<string, Map<string, { tool: string; model: string }>>();
|
|
1050
1225
|
for (const c of rawCommits) {
|
|
1051
|
-
|
|
1052
|
-
const
|
|
1226
|
+
// CI-seeded items may lack a user field — still count them in fleet totals.
|
|
1227
|
+
const userKey = c.user || '__unattributed__';
|
|
1228
|
+
const u = getUser(userKey);
|
|
1053
1229
|
u.commits.total++;
|
|
1054
1230
|
// Prefer the verdict frozen at ingest. Legacy items (no ai_origin) fall
|
|
1055
1231
|
// back to a live span join, which is only reliable inside SPAN_TTL_DAYS.
|
|
@@ -1057,12 +1233,12 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
1057
1233
|
if (c.storedOrigin === 'ai-generated' || c.storedOrigin === 'human') {
|
|
1058
1234
|
isAi = c.storedOrigin === 'ai-generated';
|
|
1059
1235
|
} else {
|
|
1060
|
-
let traceMap = traceMaps.get(c.user);
|
|
1061
|
-
if (!traceMap) {
|
|
1236
|
+
let traceMap = c.user ? traceMaps.get(c.user) : undefined;
|
|
1237
|
+
if (c.user && !traceMap) {
|
|
1062
1238
|
traceMap = await buildUserTraceMap(c.user);
|
|
1063
1239
|
traceMaps.set(c.user, traceMap);
|
|
1064
1240
|
}
|
|
1065
|
-
isAi = c.traceId !== '' && traceMap
|
|
1241
|
+
isAi = c.traceId !== '' && !!traceMap?.has(c.traceId);
|
|
1066
1242
|
}
|
|
1067
1243
|
if (isAi) {
|
|
1068
1244
|
u.commits.ai++;
|
|
@@ -1102,12 +1278,55 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
1102
1278
|
computeRatios(totals);
|
|
1103
1279
|
totals.usage.costUsd = Math.round(totals.usage.costUsd * 100) / 100;
|
|
1104
1280
|
|
|
1281
|
+
// Agents are separated from `totals`, and this is a deliberate change of meaning.
|
|
1282
|
+
//
|
|
1283
|
+
// `totals` sums every key in the map, so once the coding agent started reporting,
|
|
1284
|
+
// its spend and its commits would have flowed into the fleet AI-share and
|
|
1285
|
+
// cost-per-shipped-commit figures that the Developer Productivity and Executive
|
|
1286
|
+
// dashboards render -- at roughly $1.75 an issue, not a rounding error beside
|
|
1287
|
+
// human usage. Two things would have gone wrong at once: the human numbers would
|
|
1288
|
+
// read as worse (or better) than they are, and the autonomous signal would be
|
|
1289
|
+
// spent rather than captured, since PRISM's L5 gate is about autonomous
|
|
1290
|
+
// deployments and not about AI share.
|
|
1291
|
+
//
|
|
1292
|
+
// So `totals` is now humans, `agentTotals` is agents, and `agents` lists them.
|
|
1293
|
+
// Safe to change today because no agent data exists yet; a dashboard reading
|
|
1294
|
+
// `totals` keeps meaning what its author intended.
|
|
1295
|
+
const everyone = [...users.values()].filter(u => u.user !== '__unattributed__');
|
|
1296
|
+
const humans = everyone.filter(u => !u.autonomous);
|
|
1297
|
+
const agents = everyone.filter(u => u.autonomous);
|
|
1298
|
+
|
|
1299
|
+
const humanTotals = emptyUserProductivity('__totals__');
|
|
1300
|
+
const agentTotals = emptyUserProductivity('__agents__');
|
|
1301
|
+
for (const [group, into] of [[humans, humanTotals], [agents, agentTotals]] as const) {
|
|
1302
|
+
for (const u of group) {
|
|
1303
|
+
into.usage.inputTokens += u.usage.inputTokens;
|
|
1304
|
+
into.usage.outputTokens += u.usage.outputTokens;
|
|
1305
|
+
into.usage.costUsd += u.usage.costUsd;
|
|
1306
|
+
into.usage.calls += u.usage.calls;
|
|
1307
|
+
into.commits.total += u.commits.total;
|
|
1308
|
+
into.commits.ai += u.commits.ai;
|
|
1309
|
+
into.commits.human += u.commits.human;
|
|
1310
|
+
into.commits.mergedAi += u.commits.mergedAi;
|
|
1311
|
+
into.commits.revertedAi += u.commits.revertedAi;
|
|
1312
|
+
into.issues += u.issues;
|
|
1313
|
+
}
|
|
1314
|
+
computeRatios(into);
|
|
1315
|
+
into.usage.costUsd = Math.round(into.usage.costUsd * 100) / 100;
|
|
1316
|
+
}
|
|
1317
|
+
agentTotals.autonomous = true;
|
|
1318
|
+
|
|
1105
1319
|
return jsonResponse(200, {
|
|
1106
1320
|
range: { from, to },
|
|
1107
1321
|
scope: userParam === 'all' ? 'org' : 'user',
|
|
1108
1322
|
generatedAt: new Date().toISOString(),
|
|
1109
|
-
users:
|
|
1110
|
-
totals,
|
|
1323
|
+
users: humans.sort((a, b) => b.usage.costUsd - a.usage.costUsd),
|
|
1324
|
+
totals: humanTotals,
|
|
1325
|
+
agents: agents.sort((a, b) => b.usage.costUsd - a.usage.costUsd),
|
|
1326
|
+
agentTotals,
|
|
1327
|
+
// The pre-split figure, so a caller that genuinely wants everything does not
|
|
1328
|
+
// have to add two objects and get the ratios wrong doing it.
|
|
1329
|
+
fleetTotals: totals,
|
|
1111
1330
|
});
|
|
1112
1331
|
}
|
|
1113
1332
|
|
|
@@ -1168,7 +1387,7 @@ async function handleReposQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
|
|
|
1168
1387
|
repo: (item.pk?.S ?? '').replace(/^REPO#/, ''),
|
|
1169
1388
|
user: item.user?.S ?? '',
|
|
1170
1389
|
traceId: item.trace_id?.S ?? '',
|
|
1171
|
-
inMain: item.in_main?.BOOL ?? false,
|
|
1390
|
+
inMain: (item.in_main?.BOOL ?? false) || item.origin_source?.S === 'ci-seeded',
|
|
1172
1391
|
wasReverted: item.was_reverted?.BOOL ?? false,
|
|
1173
1392
|
ts: item.timestamp?.S ?? '',
|
|
1174
1393
|
storedOrigin: item.ai_origin?.S,
|
|
@@ -1239,6 +1458,73 @@ async function handleReposQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
|
|
|
1239
1458
|
});
|
|
1240
1459
|
}
|
|
1241
1460
|
|
|
1461
|
+
/**
|
|
1462
|
+
* GET /v1/commits-daily?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
1463
|
+
*
|
|
1464
|
+
* Returns per-day commit counts bucketed by ai_origin. Designed for dashboard
|
|
1465
|
+
* bar charts that replace the CloudWatch-native "Commits / Day (AI vs Human)"
|
|
1466
|
+
* graph — reading DDB at render time avoids the increment-only CloudWatch
|
|
1467
|
+
* limitation that makes origin upgrades (human->ai) double-count.
|
|
1468
|
+
*/
|
|
1469
|
+
async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
|
|
1470
|
+
const caller = resolveIdentity(event);
|
|
1471
|
+
if (!caller) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
|
|
1472
|
+
|
|
1473
|
+
const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
|
|
1474
|
+
const now = new Date();
|
|
1475
|
+
const from = url.searchParams.get('from') ?? new Date(now.getTime() - 30 * 86400_000).toISOString().slice(0, 10);
|
|
1476
|
+
const to = url.searchParams.get('to') ?? now.toISOString().slice(0, 10);
|
|
1477
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
|
|
1478
|
+
return jsonResponse(400, { message: 'from/to must be YYYY-MM-DD' });
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Scan COMMIT# items in the date range.
|
|
1482
|
+
const days = new Map<string, { ai: number; human: number; mergedAi: number; mergedHuman: number }>();
|
|
1483
|
+
let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
|
|
1484
|
+
do {
|
|
1485
|
+
const result = await dynamoClient.send(new ScanCommand({
|
|
1486
|
+
TableName: AI_USAGE_TABLE,
|
|
1487
|
+
FilterExpression: 'record_type = :rt AND #ts BETWEEN :lo AND :hi',
|
|
1488
|
+
ExpressionAttributeNames: { '#ts': 'timestamp' },
|
|
1489
|
+
ExpressionAttributeValues: {
|
|
1490
|
+
':rt': { S: 'OTEL_ATTR_COMMIT' },
|
|
1491
|
+
':lo': { S: from },
|
|
1492
|
+
':hi': { S: `${to}\uffff` },
|
|
1493
|
+
},
|
|
1494
|
+
ProjectionExpression: '#ts, ai_origin, in_main',
|
|
1495
|
+
...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
|
|
1496
|
+
}));
|
|
1497
|
+
for (const item of result.Items ?? []) {
|
|
1498
|
+
const ts = item.timestamp?.S ?? '';
|
|
1499
|
+
const day = ts.slice(0, 10);
|
|
1500
|
+
if (!day) continue;
|
|
1501
|
+
const origin = item.ai_origin?.S ?? 'human';
|
|
1502
|
+
const inMain = item.in_main?.BOOL ?? false;
|
|
1503
|
+
let bucket = days.get(day);
|
|
1504
|
+
if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0 }; days.set(day, bucket); }
|
|
1505
|
+
if (origin === 'ai-generated') {
|
|
1506
|
+
bucket.ai++;
|
|
1507
|
+
if (inMain) bucket.mergedAi++;
|
|
1508
|
+
} else {
|
|
1509
|
+
bucket.human++;
|
|
1510
|
+
if (inMain) bucket.mergedHuman++;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
lastKey = result.LastEvaluatedKey;
|
|
1514
|
+
} while (lastKey);
|
|
1515
|
+
|
|
1516
|
+
// Sort ascending by date and return as an array.
|
|
1517
|
+
const sorted = [...days.entries()]
|
|
1518
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
1519
|
+
.map(([date, counts]) => ({ date, ...counts }));
|
|
1520
|
+
|
|
1521
|
+
return jsonResponse(200, {
|
|
1522
|
+
range: { from, to },
|
|
1523
|
+
generatedAt: new Date().toISOString(),
|
|
1524
|
+
days: sorted,
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1242
1528
|
function accumulateUsage(u: UserProductivity, item: Record<string, { S?: string; N?: string }>): void {
|
|
1243
1529
|
const tool = item.tool?.S ?? 'unknown';
|
|
1244
1530
|
const cost = Number(item.cost_usd?.N ?? 0);
|
|
@@ -1279,5 +1565,8 @@ export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
|
|
|
1279
1565
|
if (method === 'GET' && path === '/v1/repos') {
|
|
1280
1566
|
return handleReposQuery(event);
|
|
1281
1567
|
}
|
|
1568
|
+
if (method === 'GET' && path === '/v1/commits-daily') {
|
|
1569
|
+
return handleCommitsDailyQuery(event);
|
|
1570
|
+
}
|
|
1282
1571
|
return jsonResponse(404, { message: 'Not found' });
|
|
1283
1572
|
}
|
|
@@ -36,18 +36,29 @@ full history, real commit timestamps.
|
|
|
36
36
|
### Parameters
|
|
37
37
|
| Name | Type | Default | Description |
|
|
38
38
|
|------|------|---------|-------------|
|
|
39
|
-
| view | string | table | 'table' (team comparison)
|
|
39
|
+
| view | string | table | 'table' (team comparison), 'detail' (by-tool/by-model + ratios), or 'coding-agent' (the agent's own work, kept out of the human table) |
|
|
40
40
|
| user | string | all | Developer email, or 'all' — scopes the detail view |
|
|
41
41
|
`;
|
|
42
42
|
|
|
43
43
|
type Breakdown = Record<string, { costUsd: number; calls: number }>;
|
|
44
44
|
type UserRow = {
|
|
45
|
+
issues?: number;
|
|
46
|
+
autonomous?: boolean;
|
|
45
47
|
user: string;
|
|
46
48
|
usage: { inputTokens: number; outputTokens: number; costUsd: number; calls: number; byTool: Breakdown; byModel: Breakdown };
|
|
47
49
|
commits: { total: number; ai: number; human: number; mergedAi: number; revertedAi: number };
|
|
48
50
|
ratios: { aiSharePct: number | null; mergeRatePct: number | null; defectRatePct: number | null; costPerAiCommit: number | null; costPerShippedCommit: number | null };
|
|
49
51
|
};
|
|
50
|
-
type Report = {
|
|
52
|
+
type Report = {
|
|
53
|
+
range: { from: string; to: string };
|
|
54
|
+
users: UserRow[];
|
|
55
|
+
totals: UserRow;
|
|
56
|
+
// Present once the receiver splits autonomous identities out of `totals`.
|
|
57
|
+
// Optional so this widget keeps rendering against an older receiver rather than
|
|
58
|
+
// throwing on a missing key.
|
|
59
|
+
agents?: UserRow[];
|
|
60
|
+
agentTotals?: UserRow;
|
|
61
|
+
};
|
|
51
62
|
|
|
52
63
|
// Theme palette resolved once per invocation
|
|
53
64
|
type Palette = { fg: string; mut: string; bord: string; accent: string; ok: string; warn: string };
|
|
@@ -170,6 +181,74 @@ function renderDetail(report: Report, user: string, p: Palette): string {
|
|
|
170
181
|
</div>`;
|
|
171
182
|
}
|
|
172
183
|
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The coding agent's own work, kept apart from the human table.
|
|
187
|
+
*
|
|
188
|
+
* Separate because autonomous work is not human-assisted AI use: mixing them makes
|
|
189
|
+
* the human AI-share figure mean nothing and spends the one signal PRISM has no
|
|
190
|
+
* other emitter for, since L5 is about autonomous deployments. "Issues" appears
|
|
191
|
+
* here and not in the human table because it has no human analogue -- nobody is
|
|
192
|
+
* handed issues one at a time by a label.
|
|
193
|
+
*/
|
|
194
|
+
function renderCodingAgent(report: Report, p: Palette): string {
|
|
195
|
+
const agents = report.agents ?? [];
|
|
196
|
+
if (agents.length === 0) {
|
|
197
|
+
return `
|
|
198
|
+
<div style="padding:14px 10px;color:${p.mut};line-height:1.6">
|
|
199
|
+
<div style="color:${p.fg};font-weight:bold;margin-bottom:4px">No agent activity yet</div>
|
|
200
|
+
This panel fills in once the PRISM coding agent emits usage spans. It needs
|
|
201
|
+
<code>PRISM_COLLECTOR_URL</code>, <code>PRISM_OIDC_TOKEN_ENDPOINT</code> and
|
|
202
|
+
<code>PRISM_AGENT_SECRET_ID</code> set as repository or organization
|
|
203
|
+
variables, and the harness deployed. An empty panel here means the emitter is
|
|
204
|
+
not configured — not that the agent has done nothing.
|
|
205
|
+
</div>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const t = report.agentTotals ?? agents[0];
|
|
209
|
+
const card = (label: string, value: string, note = ''): string => `
|
|
210
|
+
<div style="flex:1;min-width:120px;padding:8px 10px;border:1px solid ${p.bord};border-radius:6px">
|
|
211
|
+
<div style="color:${p.mut};font-size:11px">${label}</div>
|
|
212
|
+
<div style="color:${p.fg};font-size:20px;font-weight:bold">${value}</div>
|
|
213
|
+
${note ? `<div style="color:${p.mut};font-size:11px">${note}</div>` : ''}
|
|
214
|
+
</div>`;
|
|
215
|
+
|
|
216
|
+
const perIssue = t.issues && t.issues > 0 ? t.usage.costUsd / t.issues : null;
|
|
217
|
+
|
|
218
|
+
const rows = agents.map(a => `
|
|
219
|
+
<tr style="border-top:1px solid ${p.bord}">
|
|
220
|
+
<td style="padding:6px 10px;color:${p.accent};white-space:nowrap">${esc(a.user)}</td>
|
|
221
|
+
<td style="padding:6px 10px;text-align:right">${num(a.issues ?? 0)}</td>
|
|
222
|
+
<td style="padding:6px 10px;text-align:right">${num(a.commits.ai)}</td>
|
|
223
|
+
<td style="padding:6px 10px;text-align:right">${num(a.commits.mergedAi)}</td>
|
|
224
|
+
<td style="padding:6px 10px;text-align:right;color:${(a.ratios.mergeRatePct ?? 100) >= 85 ? p.ok : p.warn}">${pct(a.ratios.mergeRatePct)}</td>
|
|
225
|
+
<td style="padding:6px 10px;text-align:right">${pct(a.ratios.defectRatePct)}</td>
|
|
226
|
+
<td style="padding:6px 10px;text-align:right">${money(a.usage.costUsd)}</td>
|
|
227
|
+
<td style="padding:6px 10px;text-align:right">${a.ratios.costPerShippedCommit === null ? '—' : money(a.ratios.costPerShippedCommit)}</td>
|
|
228
|
+
</tr>`).join('');
|
|
229
|
+
|
|
230
|
+
const header = ['Agent', 'Issues', 'Commits', 'Shipped', 'Merge Rate', 'Defect Rate', 'Spend', '$ / Shipped']
|
|
231
|
+
.map((h, i) => `<th style="padding:6px 10px;color:${p.mut};font-weight:normal;text-align:${i === 0 ? 'left' : 'right'}">${h}</th>`)
|
|
232
|
+
.join('');
|
|
233
|
+
|
|
234
|
+
return `
|
|
235
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px">
|
|
236
|
+
${card('Issues worked', num(t.issues ?? 0))}
|
|
237
|
+
${card('Commits shipped', num(t.commits.mergedAi), `of ${num(t.commits.ai)} made`)}
|
|
238
|
+
${card('Spend', money(t.usage.costUsd), 'estimated from tokens')}
|
|
239
|
+
${card('$ / issue', perIssue === null ? '—' : money(perIssue))}
|
|
240
|
+
${card('$ / shipped', t.ratios.costPerShippedCommit === null ? '—' : money(t.ratios.costPerShippedCommit))}
|
|
241
|
+
</div>
|
|
242
|
+
<table style="border-collapse:collapse;width:100%">
|
|
243
|
+
<tr>${header}</tr>
|
|
244
|
+
${rows}
|
|
245
|
+
</table>
|
|
246
|
+
<div style="color:${p.mut};font-size:11px;margin-top:8px;line-height:1.5">
|
|
247
|
+
Excluded from the developer table and from ORG TOTAL above. Cost is estimated
|
|
248
|
+
from token counts, since the harness reports tokens and not dollars.
|
|
249
|
+
</div>`;
|
|
250
|
+
}
|
|
251
|
+
|
|
173
252
|
export async function handler(event: WidgetEvent): Promise<string> {
|
|
174
253
|
if (event.describe) return DOCS;
|
|
175
254
|
|
|
@@ -190,12 +269,14 @@ export async function handler(event: WidgetEvent): Promise<string> {
|
|
|
190
269
|
return `<p style="color:#d13212">${esc((e as Error).message)}</p>`;
|
|
191
270
|
}
|
|
192
271
|
|
|
193
|
-
const body = view === '
|
|
272
|
+
const body = view === 'coding-agent' ? renderCodingAgent(report, p)
|
|
273
|
+
: view === 'detail' ? renderDetail(report, user, p)
|
|
274
|
+
: renderTable(report, p);
|
|
194
275
|
return `
|
|
195
276
|
<div style="color:${p.fg};font-size:13px">
|
|
196
277
|
<div style="color:${p.mut};margin-bottom:6px">
|
|
197
278
|
${report.range.from} → ${report.range.to}
|
|
198
|
-
· ${report.users.length} developer(s)
|
|
279
|
+
· ${view === 'coding-agent' ? `${(report.agents ?? []).length} agent(s)` : `${report.users.length} developer(s)`}
|
|
199
280
|
· attribution store (real commit times, full history)
|
|
200
281
|
</div>
|
|
201
282
|
${body}
|