wicked-crew 0.7.39 → 0.7.40
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/api/chat-scope.d.ts +37 -1
- package/dist/api/chat-scope.d.ts.map +1 -1
- package/dist/api/chat-scope.js +131 -7
- package/dist/api/chat-scope.js.map +1 -1
- package/dist/api/routes.d.ts +19 -10
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +168 -19
- package/dist/api/routes.js.map +1 -1
- package/dist/api/run-timing-index.d.ts +10 -0
- package/dist/api/run-timing-index.d.ts.map +1 -1
- package/dist/api/run-timing-index.js +24 -0
- package/dist/api/run-timing-index.js.map +1 -1
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +33 -9
- package/dist/api/server.js.map +1 -1
- package/dist/core/adapter.d.ts +9 -2
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +19 -4
- package/dist/core/adapter.js.map +1 -1
- package/dist/projects/graph.d.ts +11 -0
- package/dist/projects/graph.d.ts.map +1 -1
- package/dist/projects/graph.js +12 -0
- package/dist/projects/graph.js.map +1 -1
- package/dist/skills/phase-skill-gaps.d.ts +108 -0
- package/dist/skills/phase-skill-gaps.d.ts.map +1 -0
- package/dist/skills/phase-skill-gaps.js +199 -0
- package/dist/skills/phase-skill-gaps.js.map +1 -0
- package/dist/skills/runtime.d.ts +1 -1
- package/dist/skills/runtime.d.ts.map +1 -1
- package/dist/skills/runtime.js +2 -1
- package/dist/skills/runtime.js.map +1 -1
- package/dist/studio/assets/{index-Drfcsy8b.css → index-Bg7Z3BR-.css} +1 -1
- package/dist/studio/assets/index-BvOFMjf9.js +554 -0
- package/dist/studio/index.html +2 -2
- package/dist/studio/testid-inventory.json +40 -10
- package/endpoint-manifest.json +1 -1
- package/package.json +2 -2
- package/dist/studio/assets/index-BRDAN35i.js +0 -548
package/dist/api/routes.js
CHANGED
|
@@ -20,13 +20,13 @@ import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
|
|
|
20
20
|
import { chatSeatAdmission } from './seat-standing.js';
|
|
21
21
|
import { rosterWithStandingFactory } from './roster-standing.js';
|
|
22
22
|
import { ChatTurnIndex } from './chat-turns.js';
|
|
23
|
-
import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, resolveChatScope, } from './chat-scope.js';
|
|
23
|
+
import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
|
|
24
24
|
import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
|
|
25
25
|
import { InvalidDiffBaseError, MERGE_BASE_LITERAL, NotARegularFileError, UnresolvableDiffBaseError, branchDiff, isPlainRef, readFileCapped, worktreeDiff, } from './run-files.js';
|
|
26
26
|
import { DocRunIndex } from '../interactive/doc-run-index.js';
|
|
27
27
|
import { listInteractiveDocs } from '../interactive/docs-index.js';
|
|
28
28
|
import { TestSetIndex } from '../qe/test-sets.js';
|
|
29
|
-
import { estateExe, resolveProjectGraphBinding } from '../projects/graph.js';
|
|
29
|
+
import { estateExe, parseEstateTotals, resolveProjectGraphBinding } from '../projects/graph.js';
|
|
30
30
|
import { registerProjectRoutes } from '../projects/routes.js';
|
|
31
31
|
import { registerCampaignRoutes } from '../campaigns/routes.js';
|
|
32
32
|
import { registerGovernanceWikiRoutes } from './governance-wiki.js';
|
|
@@ -35,6 +35,7 @@ import { isSteeringAuthorRun, landSteeringProposal } from './steering-landing.js
|
|
|
35
35
|
import { registerTestingRoutes } from './testing.js';
|
|
36
36
|
import { registerSkillsRoutes } from './skills.js';
|
|
37
37
|
import { disabledSkillsHealth } from '../skills/runtime.js';
|
|
38
|
+
import { phaseSkillFindings, withPhaseSkillGaps } from '../skills/phase-skill-gaps.js';
|
|
38
39
|
import { STATE_HOME_BLOCKER_CODE, STATE_HOME_REMEDY, isEngineStateHomeRefusal, stateHomeBlockerBody, } from '../projects/state-home-preflight.js';
|
|
39
40
|
import { BASE_SKILL_POLICIES, BASE_SKILL_REF_SHAPE, baseSkillRemedy } from '../skills/base-skill.js';
|
|
40
41
|
import { noEligibleSeatBody, parseNoEligibleSeat } from '../core/engine-roster.js';
|
|
@@ -83,17 +84,10 @@ const STATUS_ORDER = {
|
|
|
83
84
|
function sortActionableFirst(views) {
|
|
84
85
|
return [...views].sort((a, b) => (STATUS_ORDER[a.session.status] ?? 9) - (STATUS_ORDER[b.session.status] ?? 9));
|
|
85
86
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
*/
|
|
91
|
-
export function parseEstateTotals(stdout) {
|
|
92
|
-
const m = /^nodes=(\d+) edges=(\d+) files=(\d+)/m.exec(stdout);
|
|
93
|
-
if (m === null)
|
|
94
|
-
return undefined;
|
|
95
|
-
return { nodes: Number(m[1]), edges: Number(m[2]), files: Number(m[3]) };
|
|
96
|
-
}
|
|
87
|
+
// parseEstateTotals lives in projects/graph.ts — shared with chat-scope.ts (crew#642) to avoid
|
|
88
|
+
// a circular import (routes.ts → chat-scope.ts). Re-exported here for backward compat
|
|
89
|
+
// (tests import it from routes.js; see top-of-file import for local use).
|
|
90
|
+
export { parseEstateTotals };
|
|
97
91
|
function codeGraphDbOr503(repo, reply) {
|
|
98
92
|
try {
|
|
99
93
|
return codeGraphDb(repo);
|
|
@@ -526,7 +520,82 @@ export const ChatOpenSchema = z.object({
|
|
|
526
520
|
/** File the chat into a project (`crew.chat` membership) — and, with no `repoRefs`, scope it to
|
|
527
521
|
* every registered `crew.repo` member of that project (crew#502). */
|
|
528
522
|
projectId: z.string().min(1).optional(),
|
|
523
|
+
/** The scope the caller NAMES (studio#323 R4) — omitted ⇒ the legacy inference. Shape rules
|
|
524
|
+
* (which kinds need / refuse `projectId` / `repoRefs`) are the resolver's (`chat-scope.ts`). */
|
|
525
|
+
scopeKind: z.enum(['system', 'everything', 'project', 'repo', 'repos', 'none']).optional(),
|
|
529
526
|
}).strict();
|
|
527
|
+
/**
|
|
528
|
+
* The `singleSeat` disclosure (crew#641, hole closed in crew#650) — built in ONE place so the 201
|
|
529
|
+
* and the 202 cannot drift.
|
|
530
|
+
*
|
|
531
|
+
* The property this field states is **"this chat has one seat; it cannot disagree with itself"**,
|
|
532
|
+
* and that is true of every one-warm-seat chat. The first cut also required `refused.length > 0`,
|
|
533
|
+
* so the case an operator is most likely to create — a chat deliberately opened with a single seat
|
|
534
|
+
* — returned `ok: true` with no signal at all, which is the exact failure mode #641 was filed
|
|
535
|
+
* about. It also made the 202 go quiet after a restart, when `refusedOf` no longer knows the
|
|
536
|
+
* refusals, even though the chat is just as single-seated as it was a minute earlier.
|
|
537
|
+
*
|
|
538
|
+
* `refused` therefore rides along as EVIDENCE when there is any — it never decides whether the
|
|
539
|
+
* caller is told.
|
|
540
|
+
*
|
|
541
|
+
* THREE states, not two (review of #658). The refusal list has a third possibility that `[]` cannot
|
|
542
|
+
* express: this daemon does not KNOW. `chatScopes.refusedOf` answers `undefined` for a chat this
|
|
543
|
+
* daemon did not open — the engine keeps the warm session across a restart, the in-memory index does
|
|
544
|
+
* not — and the first cut collapsed that to `[]` with `?? []`, which made the 202 tell an operator
|
|
545
|
+
* *"It was opened with a single seat — no other seat was refused"* about a chat that was opened with
|
|
546
|
+
* two seats, one of which was refused. Both halves false, stated confidently. It is the same
|
|
547
|
+
* distinction #651 got right for chat-promotion provenance (`.catch(() => null)`, never `[]`):
|
|
548
|
+
* unreadable and empty are different answers, and only one of them may be asserted.
|
|
549
|
+
*
|
|
550
|
+
* TWO MOMENTS, and only one route witnessed each (third review of #658). `refused` describes the
|
|
551
|
+
* daemon's record — what the OPEN (or the last re-seat) observed; `warmRoster` describes NOW. Any
|
|
552
|
+
* sentence built from both fabricates the instant they diverge, and they diverge routinely: a seat
|
|
553
|
+
* that blows its turn budget is RELEASED by the engine (`chat-turns.ts`), a re-seat clears an entry
|
|
554
|
+
* from the record, a restart drops the record entirely. A chat opened with `['claude','opencode']`
|
|
555
|
+
* — both warm, nothing refused, nothing unknown — whose `opencode` is later released would have been
|
|
556
|
+
* told *"It was opened with a single seat — no other seat was refused"*: false in both halves, with
|
|
557
|
+
* no `undefined` anywhere near it. So `at` names the moment the caller is asking about:
|
|
558
|
+
*
|
|
559
|
+
* • `'open'` — the 201. This route just performed the open, so it is the ONLY one with standing to
|
|
560
|
+
* describe it: refusals known and empty ⇒ it may say the chat was opened with a single seat.
|
|
561
|
+
* • `'turn'` — the 202. It knows the roster NOW and the refusal record; it knows nothing about how
|
|
562
|
+
* the chat came to look like this. Refusals known and empty ⇒ it says NOTHING about opening.
|
|
563
|
+
* It still names refusals that are on record, and still says so when the record is unavailable —
|
|
564
|
+
* both are statements about the record it holds, not about a moment it did not see.
|
|
565
|
+
*
|
|
566
|
+
* `refusalsKnown` carries the known/unknown distinction for a machine reader, because `refused: []`
|
|
567
|
+
* is exactly as ambiguous on the wire as the sentence was in prose.
|
|
568
|
+
*/
|
|
569
|
+
function singleSeatDisclosure(warmed,
|
|
570
|
+
/** `undefined` = this daemon has no refusal record for the chat (it did not open it). */
|
|
571
|
+
refused,
|
|
572
|
+
/** Which moment the caller is asking about — see the note above; only `'open'` may describe one. */
|
|
573
|
+
at) {
|
|
574
|
+
const known = refused !== undefined;
|
|
575
|
+
const list = refused ?? [];
|
|
576
|
+
const refusalClause = list.length > 0
|
|
577
|
+
? `Refused: ${list.map((r) => `${r.cliKey} (${r.reason})`).join('; ')}. `
|
|
578
|
+
: known
|
|
579
|
+
? at === 'open'
|
|
580
|
+
? 'It was opened with a single seat — no other seat was refused. '
|
|
581
|
+
: // A turn has no standing to say how the chat was opened: this seat may be the survivor of
|
|
582
|
+
// a two-seat chat whose other seat was released mid-session. Say nothing.
|
|
583
|
+
''
|
|
584
|
+
: 'This daemon holds no refusal record for this chat — it was opened before a restart — so ' +
|
|
585
|
+
'whether another seat was refused is UNKNOWN: the empty refused[] means the record is gone, ' +
|
|
586
|
+
'not that nothing was refused. ';
|
|
587
|
+
return {
|
|
588
|
+
degraded: true,
|
|
589
|
+
warmed,
|
|
590
|
+
refused: list,
|
|
591
|
+
refusalsKnown: known,
|
|
592
|
+
message:
|
|
593
|
+
// "warm" on a turn: the claim is about the roster as it stands, not about how it got there.
|
|
594
|
+
`This chat has one ${at === 'open' ? 'seat' : 'warm seat'} (${warmed}); it cannot disagree with itself. ` +
|
|
595
|
+
refusalClause +
|
|
596
|
+
'The single-seat root cause is tracked as wicked-core#563; this chat makes it visible.',
|
|
597
|
+
};
|
|
598
|
+
}
|
|
530
599
|
export function registerRoutes(app, adapter, gateCache, elicitationCache,
|
|
531
600
|
// Defaulted so a caller that never arms the bus seam (tests drive this
|
|
532
601
|
// function directly) gets the same behavior as an unarmed daemon: an empty
|
|
@@ -673,6 +742,10 @@ runtime = {}) {
|
|
|
673
742
|
const guidance = guidanceIndex.guidanceFor(view.session.id);
|
|
674
743
|
if (guidance !== undefined)
|
|
675
744
|
view.session.guidance = guidance;
|
|
745
|
+
// crew#661: the declared skills this run's phases ran WITHOUT (degrade-and-disclose) — ABSENT when none.
|
|
746
|
+
const skillGaps = runtime.runSkillGaps?.gapsFor(view.session.id);
|
|
747
|
+
if (skillGaps !== undefined)
|
|
748
|
+
view.session.skill_gaps = skillGaps;
|
|
676
749
|
// Run launch time (home command-center run metrics): unix SECONDS from the `run.launched`
|
|
677
750
|
// audit entry, ABSENT when the daemon has no launch record for this run (onboarding/campaign
|
|
678
751
|
// runs launched off POST /runs, pre-field runs) — never fabricated, so a bucketed KPI can
|
|
@@ -696,6 +769,13 @@ runtime = {}) {
|
|
|
696
769
|
view.session.delivery = state.delivery;
|
|
697
770
|
if (state.deliverUrl !== undefined)
|
|
698
771
|
view.session.deliverUrl = state.deliverUrl;
|
|
772
|
+
// crew#641/#642 (item 5): chat promotion provenance — ABSENT when not a chat-promoted run.
|
|
773
|
+
const chatSeatCount = runTimingIndex.chatSeatCountFor(view.session.id);
|
|
774
|
+
if (chatSeatCount !== undefined)
|
|
775
|
+
view.session.chat_seat_count = chatSeatCount;
|
|
776
|
+
const chatGrounded = runTimingIndex.chatGroundedFor(view.session.id);
|
|
777
|
+
if (chatGrounded !== undefined)
|
|
778
|
+
view.session.chat_grounded = chatGrounded;
|
|
699
779
|
return view;
|
|
700
780
|
};
|
|
701
781
|
// Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
|
|
@@ -735,9 +815,12 @@ runtime = {}) {
|
|
|
735
815
|
// `baseSkill.finding` and `/diagnostics.skills.findings[]` (one surface, no new field;
|
|
736
816
|
// `HealthWarning.kind` is open); status stays ok because the daemon serves and studio must load
|
|
737
817
|
// and show it — exactly the state-home blocker's precedent above.
|
|
818
|
+
// crew#661: a drafting seam that armed WITHOUT its declared skill rides `warnings` as well — its
|
|
819
|
+
// runs proceed degraded, so "status ok, no warnings" would be the silent answer the issue names.
|
|
738
820
|
const warnings = [
|
|
739
821
|
...(stateHome === null ? [] : stateHome.findings.map((f) => ({ kind: f.kind, severity: f.severity, message: f.message }))),
|
|
740
822
|
...(baseSkill?.finding ? [{ kind: baseSkill.finding.kind, severity: baseSkill.finding.severity, message: baseSkill.finding.message }] : []),
|
|
823
|
+
...phaseSkillFindings(runtime.phaseSkills?.gaps() ?? []),
|
|
741
824
|
];
|
|
742
825
|
return {
|
|
743
826
|
status: 'ok',
|
|
@@ -790,7 +873,9 @@ runtime = {}) {
|
|
|
790
873
|
// The skills seam's last outcome (skills keystone): published / fallback / blocked /
|
|
791
874
|
// config-error, with the `skills.*` findings the ladder produced — the operator's one
|
|
792
875
|
// read-only answer to "why do launches refuse the snapshot".
|
|
793
|
-
|
|
876
|
+
// crew#661: plus every subsystem whose phases ARMED without a declared skill (`phaseSkillGaps`,
|
|
877
|
+
// one `skills.phase-skill` finding each) — the degraded runs' operator-facing record.
|
|
878
|
+
skills: withPhaseSkillGaps(runtime.skills?.health() ?? disabledSkillsHealth(), runtime.phaseSkills),
|
|
794
879
|
// Is the governance evidence LANDING (crew#495): the store, the records on it, the dead
|
|
795
880
|
// letters — with a `governance.deadletter` finding the moment the outbox holds one.
|
|
796
881
|
governance,
|
|
@@ -1312,6 +1397,23 @@ runtime = {}) {
|
|
|
1312
1397
|
}
|
|
1313
1398
|
try {
|
|
1314
1399
|
const runId = await adapter.launchRun(input);
|
|
1400
|
+
// crew#641/#642 (item 5): when a run is promoted from a chat, record how many seats
|
|
1401
|
+
// participated and whether the chat was grounded — so an intent authored by a single
|
|
1402
|
+
// ungrounded model is distinguishable from one that survived a disagreement.
|
|
1403
|
+
let chatSeatCount;
|
|
1404
|
+
let chatGrounded;
|
|
1405
|
+
// Both stay ABSENT unless the chat actually resolves: a closed or unknown chatId must read as
|
|
1406
|
+
// "provenance unavailable", not as "promoted from an ungrounded zero-seat chat". A valid chat
|
|
1407
|
+
// always has at least one warm seat (`crew-api-types` ChatOpenResponse), so 0 is not a value
|
|
1408
|
+
// `chat_seat_count` can honestly hold.
|
|
1409
|
+
if (b.chatId !== undefined) {
|
|
1410
|
+
const promotedFrom = await adapter.chatSeats(b.chatId).catch(() => null);
|
|
1411
|
+
const scope = chatScopes.engineOf(b.chatId);
|
|
1412
|
+
if (promotedFrom !== null && promotedFrom.length > 0 && scope !== undefined) {
|
|
1413
|
+
chatSeatCount = promotedFrom.length;
|
|
1414
|
+
chatGrounded = scope.codeGraphDb != null;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1315
1417
|
// Who launched it — the engine's LaunchOptions carries no actor field
|
|
1316
1418
|
// (checked, wicked-core-ts 0.6.0), so the crew-side trail is the system
|
|
1317
1419
|
// of record for run provenance (task #88).
|
|
@@ -1344,10 +1446,17 @@ runtime = {}) {
|
|
|
1344
1446
|
// crew#619: the chat↔run link is durable so the retention maps can be rehydrated after a
|
|
1345
1447
|
// daemon restart (rehydration reads `run.launched` entries and keeps non-terminal entries).
|
|
1346
1448
|
...(b.chatId !== undefined ? { chatId: b.chatId } : {}),
|
|
1449
|
+
// crew#641/#642 (item 5): chat promotion provenance — seat count and grounding at launch.
|
|
1450
|
+
...(chatSeatCount !== undefined ? { chatSeatCount } : {}),
|
|
1451
|
+
...(chatGrounded !== undefined ? { chatGrounded } : {}),
|
|
1347
1452
|
// crew#632: launch surface and caller identity — human-readable provenance on the audit trail.
|
|
1348
1453
|
...(b.channel !== undefined ? { channel: b.channel } : {}),
|
|
1349
1454
|
...(b.actor !== undefined ? { actor: b.actor } : {}),
|
|
1350
1455
|
});
|
|
1456
|
+
// Stamp the index live so the field is served without waiting for a daemon restart.
|
|
1457
|
+
if (chatSeatCount !== undefined && chatGrounded !== undefined) {
|
|
1458
|
+
runTimingIndex.setChatPromotion(runId, chatSeatCount, chatGrounded);
|
|
1459
|
+
}
|
|
1351
1460
|
if (b.retryOf !== undefined)
|
|
1352
1461
|
retryIndex.set(runId, b.retryOf);
|
|
1353
1462
|
if (revisesPr !== undefined)
|
|
@@ -1826,6 +1935,12 @@ runtime = {}) {
|
|
|
1826
1935
|
}
|
|
1827
1936
|
const b = parsed.data;
|
|
1828
1937
|
const chatId = b.chatId ?? randomUUID();
|
|
1938
|
+
const requestedRefs = [...(b.repoRef !== undefined ? [b.repoRef] : []), ...(b.repoRefs ?? [])];
|
|
1939
|
+
// A named scope's SHAPE first (studio#323 R4, codex on #664): a body whose kind refuses its own
|
|
1940
|
+
// projectId/repoRefs is a 400 — never masked by the project lookup's 404/409 below.
|
|
1941
|
+
const shape = namedKindRefusal(b.scopeKind, b.projectId, [...new Set(requestedRefs)]);
|
|
1942
|
+
if (shape !== null)
|
|
1943
|
+
return reply.code(400).send({ error: shape.error });
|
|
1829
1944
|
// Validate the project BEFORE opening seats: a chat has no launch record for the engine to
|
|
1830
1945
|
// attach against atomically (chats are an in-memory seat pool), so the route validates
|
|
1831
1946
|
// up-front and attaches right after open — the one non-atomic attach, documented in the ADR
|
|
@@ -1866,8 +1981,14 @@ runtime = {}) {
|
|
|
1866
1981
|
const resolution = await resolveChatScope({
|
|
1867
1982
|
chatId,
|
|
1868
1983
|
...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
|
|
1869
|
-
repoRefs:
|
|
1870
|
-
|
|
1984
|
+
repoRefs: requestedRefs,
|
|
1985
|
+
...(b.scopeKind !== undefined ? { kind: b.scopeKind } : {}),
|
|
1986
|
+
}, {
|
|
1987
|
+
...chatScopeDeps(adapter),
|
|
1988
|
+
scratchBase: chatScopes.base,
|
|
1989
|
+
log: (m) => req.log.warn(m),
|
|
1990
|
+
...(runtime.entityCount !== undefined ? { entityCount: runtime.entityCount } : {}),
|
|
1991
|
+
});
|
|
1871
1992
|
if (!resolution.ok) {
|
|
1872
1993
|
chatScopes.release(chatId, token);
|
|
1873
1994
|
return reply.code(resolution.status).send({
|
|
@@ -1895,7 +2016,10 @@ runtime = {}) {
|
|
|
1895
2016
|
// here is NAMED on the response (`refused`) and in the thread (`chatSeatRefused`), so a
|
|
1896
2017
|
// person can see why pi is missing. Explicit `clis` are passed through as asked; the engine
|
|
1897
2018
|
// refuses per seat with its reason, which is copied into `refused` too.
|
|
1898
|
-
|
|
2019
|
+
// A chat is SCOPED — held to read-only roots — when it reads repositories by its kind. `none`
|
|
2020
|
+
// and `system` (studio#323 R4: the platform itself, no repository) read none, so they take the
|
|
2021
|
+
// unscoped admission and open on an engine that predates chat scope.
|
|
2022
|
+
const scoped = scope.kind !== 'none' && scope.kind !== 'system';
|
|
1899
2023
|
const refused = [];
|
|
1900
2024
|
// The standing roster, read ONCE: the default admission below and the engine-drop
|
|
1901
2025
|
// attribution after `chatOpen` both consult it (F-A45-011).
|
|
@@ -2067,12 +2191,17 @@ runtime = {}) {
|
|
|
2067
2191
|
...(b.projectId !== undefined ? { project_id: b.projectId } : {}),
|
|
2068
2192
|
});
|
|
2069
2193
|
}
|
|
2194
|
+
// crew#641: a chat with exactly ONE warm seat cannot disagree with itself — disclosed
|
|
2195
|
+
// prominently so the caller does not have to reason about the refused[] array.
|
|
2196
|
+
const warm = seats.filter((s) => s.ok);
|
|
2197
|
+
const singleSeat = warm.length === 1 ? singleSeatDisclosure(warm[0].cliKey, refused, 'open') : undefined;
|
|
2070
2198
|
return reply.code(201).send({
|
|
2071
2199
|
chatId,
|
|
2072
2200
|
seats,
|
|
2073
2201
|
scope,
|
|
2074
2202
|
refused,
|
|
2075
2203
|
...(projectAttachError !== undefined ? { projectAttachError } : {}),
|
|
2204
|
+
...(singleSeat !== undefined ? { singleSeat } : {}),
|
|
2076
2205
|
});
|
|
2077
2206
|
}
|
|
2078
2207
|
catch (err) {
|
|
@@ -2123,7 +2252,12 @@ runtime = {}) {
|
|
|
2123
2252
|
// warm seats — so the turn can be opened BEFORE the engine call, in the same tick as the
|
|
2124
2253
|
// predicate below: two racing sends can no longer both pass `inFlight` during the await, and a
|
|
2125
2254
|
// `chatDelta` that lands before `chatSend` resolves is already stamped with its `turn_id`.
|
|
2126
|
-
|
|
2255
|
+
// crew#641 (review): the ROSTER and the AUDIENCE are different things and only one of them can
|
|
2256
|
+
// answer "can this chat disagree with itself". Resolved ONCE here: `audience` is who this turn
|
|
2257
|
+
// goes to (the named targets, else everyone warm), `warmRoster` is who is warm at all. A
|
|
2258
|
+
// targeted send to one seat of a two-seat chat is not a degraded chat.
|
|
2259
|
+
const warmRoster = await adapter.chatSeats(id);
|
|
2260
|
+
const audience = parsed.data.targets ?? warmRoster;
|
|
2127
2261
|
if (audience.length === 0) {
|
|
2128
2262
|
return reply.code(409).send({ error: `chat '${id}' has no warm seats — open it first` });
|
|
2129
2263
|
}
|
|
@@ -2141,7 +2275,22 @@ runtime = {}) {
|
|
|
2141
2275
|
chatTurns.reconcile(id, turn.turnId, seats);
|
|
2142
2276
|
chatTranscripts?.appendUser(id, turn.turnId, parsed.data.text, seats);
|
|
2143
2277
|
}
|
|
2144
|
-
|
|
2278
|
+
// crew#641: re-state single-seat degradation on every turn so it is visible in the transcript.
|
|
2279
|
+
// Decided from `warmRoster` — the seats that are WARM — never from `seats`, which is only the
|
|
2280
|
+
// seats this turn reached: a targeted send to one seat of a two-seat chat would otherwise
|
|
2281
|
+
// announce a degradation that does not exist, and a false "you are degraded" sends an operator
|
|
2282
|
+
// after a problem they do not have.
|
|
2283
|
+
// crew#650: `refusedOf` is `undefined` for a chat THIS daemon did not open (a restart drops
|
|
2284
|
+
// the index) — that is "the refusals are unknown", not "the chat gained a seat". The
|
|
2285
|
+
// disclosure is decided by the warm roster alone, and the `undefined` is handed STRAIGHT to
|
|
2286
|
+
// the builder (never `?? []`, review of #658): only it may decide what an unknown record is
|
|
2287
|
+
// allowed to claim.
|
|
2288
|
+
const singleSeat202 = warmRoster.length === 1 ? singleSeatDisclosure(warmRoster[0], chatScopes.refusedOf(id), 'turn') : undefined;
|
|
2289
|
+
return reply.code(202).send({
|
|
2290
|
+
seats,
|
|
2291
|
+
...(turn !== null ? { turnId: turn.turnId } : {}),
|
|
2292
|
+
...(singleSeat202 !== undefined ? { singleSeat: singleSeat202 } : {}),
|
|
2293
|
+
});
|
|
2145
2294
|
}
|
|
2146
2295
|
catch (err) {
|
|
2147
2296
|
// Nothing went out: the reservation is retracted so the next send is not refused for it.
|