wicked-crew 0.16.11 → 0.16.13
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/README.md +28 -8
- package/dist/api/artifact-outline.d.ts +64 -0
- package/dist/api/artifact-outline.d.ts.map +1 -0
- package/dist/api/artifact-outline.js +254 -0
- package/dist/api/artifact-outline.js.map +1 -0
- package/dist/api/beneath.d.ts +76 -0
- package/dist/api/beneath.d.ts.map +1 -0
- package/dist/api/beneath.js +214 -0
- package/dist/api/beneath.js.map +1 -0
- package/dist/api/capture.d.ts +2 -2
- package/dist/api/chat-citations.d.ts +25 -1
- package/dist/api/chat-citations.d.ts.map +1 -1
- package/dist/api/chat-citations.js +186 -61
- package/dist/api/chat-citations.js.map +1 -1
- package/dist/api/chat-context.d.ts +236 -0
- package/dist/api/chat-context.d.ts.map +1 -0
- package/dist/api/chat-context.js +808 -0
- package/dist/api/chat-context.js.map +1 -0
- package/dist/api/chat-history.d.ts +20 -0
- package/dist/api/chat-history.d.ts.map +1 -1
- package/dist/api/chat-history.js +46 -1
- package/dist/api/chat-history.js.map +1 -1
- package/dist/api/chat-proposals.d.ts +534 -0
- package/dist/api/chat-proposals.d.ts.map +1 -0
- package/dist/api/chat-proposals.js +1562 -0
- package/dist/api/chat-proposals.js.map +1 -0
- package/dist/api/chat-scope.d.ts +41 -0
- package/dist/api/chat-scope.d.ts.map +1 -1
- package/dist/api/chat-scope.js +87 -15
- package/dist/api/chat-scope.js.map +1 -1
- package/dist/api/chat-transcripts.d.ts +27 -2
- package/dist/api/chat-transcripts.d.ts.map +1 -1
- package/dist/api/chat-transcripts.js +105 -4
- package/dist/api/chat-transcripts.js.map +1 -1
- package/dist/api/diagnostics.d.ts +15 -0
- package/dist/api/diagnostics.d.ts.map +1 -1
- package/dist/api/diagnostics.js +56 -0
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/api/elicitation-cache.d.ts +13 -0
- package/dist/api/elicitation-cache.d.ts.map +1 -1
- package/dist/api/elicitation-cache.js +12 -0
- package/dist/api/elicitation-cache.js.map +1 -1
- package/dist/api/eval-sample.d.ts +12 -12
- package/dist/api/frame-guard.d.ts +62 -0
- package/dist/api/frame-guard.d.ts.map +1 -0
- package/dist/api/frame-guard.js +97 -0
- package/dist/api/frame-guard.js.map +1 -0
- package/dist/api/governance-steering.d.ts +4 -4
- package/dist/api/multiscope.d.ts +3 -0
- package/dist/api/multiscope.d.ts.map +1 -1
- package/dist/api/multiscope.js +13 -0
- package/dist/api/multiscope.js.map +1 -1
- package/dist/api/needs.d.ts +6 -0
- package/dist/api/needs.d.ts.map +1 -1
- package/dist/api/needs.js +31 -4
- package/dist/api/needs.js.map +1 -1
- package/dist/api/product.d.ts.map +1 -1
- package/dist/api/product.js +13 -2
- package/dist/api/product.js.map +1 -1
- package/dist/api/project-aggregates.d.ts +6 -6
- package/dist/api/project-aggregates.d.ts.map +1 -1
- package/dist/api/project-aggregates.js +16 -24
- package/dist/api/project-aggregates.js.map +1 -1
- package/dist/api/requirements.d.ts +261 -6
- package/dist/api/requirements.d.ts.map +1 -1
- package/dist/api/requirements.js +344 -64
- package/dist/api/requirements.js.map +1 -1
- package/dist/api/routes.d.ts +156 -4
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +851 -78
- package/dist/api/routes.js.map +1 -1
- package/dist/api/run-brief.d.ts +78 -0
- package/dist/api/run-brief.d.ts.map +1 -0
- package/dist/api/run-brief.js +318 -0
- package/dist/api/run-brief.js.map +1 -0
- package/dist/api/run-files.d.ts.map +1 -1
- package/dist/api/run-files.js +5 -1
- package/dist/api/run-files.js.map +1 -1
- package/dist/api/run-timing-index.d.ts +27 -0
- package/dist/api/run-timing-index.d.ts.map +1 -1
- package/dist/api/run-timing-index.js +65 -0
- package/dist/api/run-timing-index.js.map +1 -1
- package/dist/api/seat-images.d.ts +102 -0
- package/dist/api/seat-images.d.ts.map +1 -0
- package/dist/api/seat-images.js +333 -0
- package/dist/api/seat-images.js.map +1 -0
- package/dist/api/server.d.ts +3 -0
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +265 -2
- package/dist/api/server.js.map +1 -1
- package/dist/api/steering-landing.d.ts +48 -1
- package/dist/api/steering-landing.d.ts.map +1 -1
- package/dist/api/steering-landing.js +116 -42
- package/dist/api/steering-landing.js.map +1 -1
- package/dist/api/system-snapshot.d.ts +51 -2
- package/dist/api/system-snapshot.d.ts.map +1 -1
- package/dist/api/system-snapshot.js +151 -15
- package/dist/api/system-snapshot.js.map +1 -1
- package/dist/api/testing.d.ts +4 -4
- package/dist/api/testing.js +2 -2
- package/dist/api/testing.js.map +1 -1
- package/dist/apps/spec.d.ts +3009 -0
- package/dist/apps/spec.d.ts.map +1 -0
- package/dist/apps/spec.js +870 -0
- package/dist/apps/spec.js.map +1 -0
- package/dist/campaigns/routes.js +1 -1
- package/dist/campaigns/routes.js.map +1 -1
- package/dist/cli/index.js +3 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +12 -0
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +36 -1
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/deliver-text.d.ts.map +1 -1
- package/dist/core/deliver-text.js +22 -10
- package/dist/core/deliver-text.js.map +1 -1
- package/dist/core/estate-mcp-client.d.ts +7 -1
- package/dist/core/estate-mcp-client.d.ts.map +1 -1
- package/dist/core/estate-mcp-client.js +10 -2
- package/dist/core/estate-mcp-client.js.map +1 -1
- package/dist/core/exec.d.ts +12 -0
- package/dist/core/exec.d.ts.map +1 -1
- package/dist/core/exec.js +23 -0
- package/dist/core/exec.js.map +1 -1
- package/dist/core/reassign-refusal.d.ts.map +1 -1
- package/dist/core/reassign-refusal.js +23 -3
- package/dist/core/reassign-refusal.js.map +1 -1
- package/dist/core/repoPaths.d.ts +32 -7
- package/dist/core/repoPaths.d.ts.map +1 -1
- package/dist/core/repoPaths.js +56 -9
- package/dist/core/repoPaths.js.map +1 -1
- package/dist/core/requirements-graph-setting.d.ts +32 -0
- package/dist/core/requirements-graph-setting.d.ts.map +1 -0
- package/dist/core/requirements-graph-setting.js +84 -0
- package/dist/core/requirements-graph-setting.js.map +1 -0
- package/dist/core/run-boot-resume.d.ts +4 -1
- package/dist/core/run-boot-resume.d.ts.map +1 -1
- package/dist/core/run-boot-resume.js +6 -1
- package/dist/core/run-boot-resume.js.map +1 -1
- package/dist/core/run-identity.d.ts.map +1 -1
- package/dist/core/run-identity.js +6 -0
- package/dist/core/run-identity.js.map +1 -1
- package/dist/core/types.d.ts +14 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/decisions/chat-recorder.d.ts +14 -3
- package/dist/decisions/chat-recorder.d.ts.map +1 -1
- package/dist/decisions/chat-recorder.js +54 -9
- package/dist/decisions/chat-recorder.js.map +1 -1
- package/dist/editors/csp.d.ts +24 -4
- package/dist/editors/csp.d.ts.map +1 -1
- package/dist/editors/csp.js +35 -8
- package/dist/editors/csp.js.map +1 -1
- package/dist/editors/registry.d.ts +46 -21
- package/dist/editors/registry.d.ts.map +1 -1
- package/dist/editors/registry.js +13 -2
- package/dist/editors/registry.js.map +1 -1
- package/dist/editors/routes.d.ts +4 -1
- package/dist/editors/routes.d.ts.map +1 -1
- package/dist/editors/routes.js +30 -3
- package/dist/editors/routes.js.map +1 -1
- package/dist/editors/visuals.d.ts +51 -0
- package/dist/editors/visuals.d.ts.map +1 -0
- package/dist/editors/visuals.js +164 -0
- package/dist/editors/visuals.js.map +1 -0
- package/dist/editors/webrtc-guard.d.ts +94 -0
- package/dist/editors/webrtc-guard.d.ts.map +1 -0
- package/dist/editors/webrtc-guard.js +506 -0
- package/dist/editors/webrtc-guard.js.map +1 -0
- package/dist/inspect/reading.d.ts +110 -0
- package/dist/inspect/reading.d.ts.map +1 -0
- package/dist/inspect/reading.js +325 -0
- package/dist/inspect/reading.js.map +1 -0
- package/dist/inspect/routes.d.ts +14 -0
- package/dist/inspect/routes.d.ts.map +1 -0
- package/dist/inspect/routes.js +44 -0
- package/dist/inspect/routes.js.map +1 -0
- package/dist/inspect/service.d.ts +161 -0
- package/dist/inspect/service.d.ts.map +1 -0
- package/dist/inspect/service.js +466 -0
- package/dist/inspect/service.js.map +1 -0
- package/dist/interactive/bridge-pool.d.ts +11 -2
- package/dist/interactive/bridge-pool.d.ts.map +1 -1
- package/dist/interactive/bridge-pool.js +10 -1
- package/dist/interactive/bridge-pool.js.map +1 -1
- package/dist/interactive/doc-binding.d.ts +156 -0
- package/dist/interactive/doc-binding.d.ts.map +1 -0
- package/dist/interactive/doc-binding.js +277 -0
- package/dist/interactive/doc-binding.js.map +1 -0
- package/dist/interactive/doc-delete-routes.d.ts +3 -0
- package/dist/interactive/doc-delete-routes.d.ts.map +1 -1
- package/dist/interactive/doc-delete-routes.js +4 -0
- package/dist/interactive/doc-delete-routes.js.map +1 -1
- package/dist/interactive/draft-events.d.ts +89 -3
- package/dist/interactive/draft-events.d.ts.map +1 -1
- package/dist/interactive/draft-events.js +441 -30
- package/dist/interactive/draft-events.js.map +1 -1
- package/dist/interactive/draft-skill.d.ts +7 -0
- package/dist/interactive/draft-skill.d.ts.map +1 -1
- package/dist/interactive/draft-skill.js +10 -3
- package/dist/interactive/draft-skill.js.map +1 -1
- package/dist/interactive/edit-events.d.ts +2 -0
- package/dist/interactive/edit-events.d.ts.map +1 -1
- package/dist/interactive/edit-events.js +11 -0
- package/dist/interactive/edit-events.js.map +1 -1
- package/dist/interactive/proposal-create.d.ts +143 -0
- package/dist/interactive/proposal-create.d.ts.map +1 -0
- package/dist/interactive/proposal-create.js +318 -0
- package/dist/interactive/proposal-create.js.map +1 -0
- package/dist/interactive/proxy-routes.d.ts +60 -3
- package/dist/interactive/proxy-routes.d.ts.map +1 -1
- package/dist/interactive/proxy-routes.js +217 -3
- package/dist/interactive/proxy-routes.js.map +1 -1
- package/dist/interactive/source-packet.d.ts +126 -0
- package/dist/interactive/source-packet.d.ts.map +1 -0
- package/dist/interactive/source-packet.js +482 -0
- package/dist/interactive/source-packet.js.map +1 -0
- package/dist/learnings/estate.d.ts +16 -0
- package/dist/learnings/estate.d.ts.map +1 -0
- package/dist/learnings/estate.js +68 -0
- package/dist/learnings/estate.js.map +1 -0
- package/dist/learnings/extract.d.ts +56 -0
- package/dist/learnings/extract.d.ts.map +1 -0
- package/dist/learnings/extract.js +252 -0
- package/dist/learnings/extract.js.map +1 -0
- package/dist/learnings/recall.d.ts +43 -0
- package/dist/learnings/recall.d.ts.map +1 -0
- package/dist/learnings/recall.js +104 -0
- package/dist/learnings/recall.js.map +1 -0
- package/dist/learnings/service.d.ts +183 -0
- package/dist/learnings/service.d.ts.map +1 -0
- package/dist/learnings/service.js +571 -0
- package/dist/learnings/service.js.map +1 -0
- package/dist/mcp/ask-server.d.ts.map +1 -1
- package/dist/mcp/ask-server.js +37 -4
- package/dist/mcp/ask-server.js.map +1 -1
- package/dist/mcp/broker.d.ts +12 -1
- package/dist/mcp/broker.d.ts.map +1 -1
- package/dist/mcp/broker.js +24 -5
- package/dist/mcp/broker.js.map +1 -1
- package/dist/mcp/invoke.d.ts +26 -2
- package/dist/mcp/invoke.d.ts.map +1 -1
- package/dist/mcp/invoke.js +51 -5
- package/dist/mcp/invoke.js.map +1 -1
- package/dist/persona/routes.d.ts +92 -0
- package/dist/persona/routes.d.ts.map +1 -0
- package/dist/persona/routes.js +69 -0
- package/dist/persona/routes.js.map +1 -0
- package/dist/persona/store.d.ts +104 -0
- package/dist/persona/store.d.ts.map +1 -0
- package/dist/persona/store.js +176 -0
- package/dist/persona/store.js.map +1 -0
- package/dist/persona/voice.d.ts +4 -0
- package/dist/persona/voice.d.ts.map +1 -0
- package/dist/persona/voice.js +58 -0
- package/dist/persona/voice.js.map +1 -0
- package/dist/projects/routes.d.ts +2 -2
- package/dist/setup/known.d.ts +56 -0
- package/dist/setup/known.d.ts.map +1 -0
- package/dist/setup/known.js +159 -0
- package/dist/setup/known.js.map +1 -0
- package/dist/setup/routes.d.ts +13 -0
- package/dist/setup/routes.d.ts.map +1 -0
- package/dist/setup/routes.js +20 -0
- package/dist/setup/routes.js.map +1 -0
- package/dist/standing-orders/history.d.ts +2 -0
- package/dist/standing-orders/history.d.ts.map +1 -1
- package/dist/standing-orders/history.js +1 -0
- package/dist/standing-orders/history.js.map +1 -1
- package/dist/studio/assets/WickedSans-Regular-fb698rcD.woff2 +0 -0
- package/dist/studio/assets/WickedSans-SemiBold-CD53n1AC.woff2 +0 -0
- package/dist/studio/assets/WickedScript-Regular-CY5juO6I.woff2 +0 -0
- package/dist/studio/assets/WickedSerif-Regular-mdPEjaVZ.woff2 +0 -0
- package/dist/studio/assets/index-BUZ_-Syk.css +32 -0
- package/dist/studio/assets/index-u4O_7N-b.js +986 -0
- package/dist/studio/editors/wicked-deck/editor.json +6 -1
- package/dist/studio/editors/wicked-deck/icon.svg +1 -0
- package/dist/studio/editors/wicked-deck/index.html +1 -1
- package/dist/studio/editors/wicked-doc/editor.json +6 -1
- package/dist/studio/editors/wicked-doc/icon.svg +1 -0
- package/dist/studio/editors/wicked-doc/index.html +1 -1
- package/dist/studio/editors/wicked-insights/editor.json +6 -1
- package/dist/studio/editors/wicked-insights/icon.svg +1 -0
- package/dist/studio/editors/wicked-insights/index.html +1 -1
- package/dist/studio/editors/wicked-page/editor.json +6 -1
- package/dist/studio/editors/wicked-page/icon.svg +1 -0
- package/dist/studio/editors/wicked-page/index.html +1 -1
- package/dist/studio/editors/wicked-video/editor.json +6 -1
- package/dist/studio/editors/wicked-video/icon.svg +1 -0
- package/dist/studio/editors/wicked-video/index.html +1 -1
- package/dist/studio/index.html +2 -2
- package/dist/studio/testid-inventory.json +5155 -2707
- package/dist/voice/dashboard.d.ts +376 -0
- package/dist/voice/dashboard.d.ts.map +1 -0
- package/dist/voice/dashboard.js +92 -0
- package/dist/voice/dashboard.js.map +1 -0
- package/dist/voice/inject-input.d.ts +51 -0
- package/dist/voice/inject-input.d.ts.map +1 -0
- package/dist/voice/inject-input.js +126 -0
- package/dist/voice/inject-input.js.map +1 -0
- package/dist/voice/routes.d.ts +41 -0
- package/dist/voice/routes.d.ts.map +1 -0
- package/dist/voice/routes.js +116 -0
- package/dist/voice/routes.js.map +1 -0
- package/dist/voice/service.d.ts +177 -0
- package/dist/voice/service.d.ts.map +1 -0
- package/dist/voice/service.js +400 -0
- package/dist/voice/service.js.map +1 -0
- package/dist/workflow-catalog/build.d.ts +35 -0
- package/dist/workflow-catalog/build.d.ts.map +1 -0
- package/dist/workflow-catalog/build.js +229 -0
- package/dist/workflow-catalog/build.js.map +1 -0
- package/dist/workflow-catalog/catalog-data.d.ts +36 -0
- package/dist/workflow-catalog/catalog-data.d.ts.map +1 -0
- package/dist/workflow-catalog/catalog-data.js +430 -0
- package/dist/workflow-catalog/catalog-data.js.map +1 -0
- package/dist/working-doc/event-lines.d.ts +76 -0
- package/dist/working-doc/event-lines.d.ts.map +1 -0
- package/dist/working-doc/event-lines.js +247 -0
- package/dist/working-doc/event-lines.js.map +1 -0
- package/dist/working-doc/routes.d.ts +1 -1
- package/dist/working-doc/routes.d.ts.map +1 -1
- package/dist/working-doc/routes.js +2 -1
- package/dist/working-doc/routes.js.map +1 -1
- package/dist/working-doc/sections.d.ts +3 -1
- package/dist/working-doc/sections.d.ts.map +1 -1
- package/dist/working-doc/sections.js +4 -2
- package/dist/working-doc/sections.js.map +1 -1
- package/dist/working-doc/service.d.ts +66 -1
- package/dist/working-doc/service.d.ts.map +1 -1
- package/dist/working-doc/service.js +298 -22
- package/dist/working-doc/service.js.map +1 -1
- package/dist/working-doc/store.d.ts +8 -2
- package/dist/working-doc/store.d.ts.map +1 -1
- package/dist/working-doc/store.js.map +1 -1
- package/endpoint-manifest.json +205 -2
- package/package.json +5 -4
- package/workflows/distill-learnings.json +27 -0
- package/workflows/inspect-unit.json +27 -0
- package/dist/studio/assets/index-B-f_VEL2.css +0 -32
- package/dist/studio/assets/index-DhkxIK-0.js +0 -845
|
@@ -0,0 +1,1562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHAT PROPOSES; NOTHING LAUNCHES YET (DES-CHAT-ARTIFACTS-001 §4, slice A2).
|
|
3
|
+
*
|
|
4
|
+
* A chat seat that is asked to make or change an artifact ends its reply with a fenced `wicked-make` block
|
|
5
|
+
* (`chat-scope.ts` tells it how, for the operations crew advertises). The block never reaches a reader: the transcript
|
|
6
|
+
* store cuts it out of the reply and the stream (`chat-transcripts.ts`, on the `wicked-decisions` mechanism). This
|
|
7
|
+
* module is everything after that:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Resolve** ({@link ChatProposals.onReply}): the last block that parses becomes an {@link ArtifactProposal}
|
|
10
|
+
* whose every line is crew's — the catalog entry and its outcome, each source authorized (a run or document must
|
|
11
|
+
* have been named by the person in this chat or be the artifact they had open, and be in the chat's scope; a
|
|
12
|
+
* struck chip says why), the repositories (the chat's scope, else Go is blocked), two distinct helpers (the
|
|
13
|
+
* chat's lead seat, then another of its seats, else the next eligible seat on the roster: studio's `docSeats`
|
|
14
|
+
* rule, moved here so the card and the launch agree), time and cost from the catalog and from completed runs on
|
|
15
|
+
* this daemon (a dollar figure only from at least 3 runs whose every agent step reported a cost), the stops and
|
|
16
|
+
* where it is filed. It is appended as a `proposal` transcript record and sent as a `chatProposal` frame. A newer
|
|
17
|
+
* proposal supersedes the chat's open one; an answer-only turn leaves it open.
|
|
18
|
+
* 2. **Answer** ({@link ChatProposals.decide}, `POST /chats/:id/proposals/:pid`): the route has already checked the
|
|
19
|
+
* capability, that the caller is a person, and that the chat is theirs — on every call. Then, under one
|
|
20
|
+
* in-process lock per proposal, crew looks for what already happened first (a decided proposal returns its
|
|
21
|
+
* result; an `accepting` one whose run exists is adopted and decided), and only a fresh Go runs the state checks
|
|
22
|
+
* (edits, that every source and the target still exist, that the chat and the document are not busy, that a
|
|
23
|
+
* refine's document is still on the head it was proposed on) BEFORE anything is recorded. Then `proposal_accepting`
|
|
24
|
+
* (the effective proposal and the identity derived from the pid), then the one launcher per run id
|
|
25
|
+
* ({@link LaunchOnceRegistry}), then `proposal_decided`. Not now runs no other check.
|
|
26
|
+
*
|
|
27
|
+
* Each operation's launcher is registered by its own slice: A3's real `create` (`interactive/proposal-create.ts`)
|
|
28
|
+
* whenever the draft seam is armed, so production advertises `proposals` and `create`; refine (A5), theme (A7) and demo
|
|
29
|
+
* (A8) stay off there until their slices land. The TEST configuration (`WICKED_CHAT_ARTIFACTS_STUB=1`, DES §13 A2)
|
|
30
|
+
* turns every operation's resolution on and registers a {@link RecordingStubDispatcher} for the kinds that have no real
|
|
31
|
+
* launcher yet (`capabilities.chatArtifacts.stubOps`): those record a dispatch and start nothing.
|
|
32
|
+
*
|
|
33
|
+
* Records go to the chat's live transcript, or to its kept history copy once the chat has ended, so an open proposal
|
|
34
|
+
* stays answerable after a restart (§4.3).
|
|
35
|
+
*/
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
37
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
38
|
+
import { dirname } from 'node:path';
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
import { artifactExists, artifactHead, chatMount, parseArtifactRef, UNFILED_MOUNT } from './chat-context.js';
|
|
41
|
+
import { tornTailPrefix } from './chat-transcripts.js';
|
|
42
|
+
// ── the block ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
/** Sources a block may name. */
|
|
44
|
+
export const PROPOSAL_SOURCES_MAX = 12;
|
|
45
|
+
const TOKEN = z.string().trim().min(1).max(300);
|
|
46
|
+
const SOURCES = z.array(TOKEN).max(PROPOSAL_SOURCES_MAX).default([]);
|
|
47
|
+
const REPOS = z.array(z.string().trim().min(1).max(200)).max(8).default([]);
|
|
48
|
+
const REASON = z.string().max(600).optional();
|
|
49
|
+
/** studio's appearance scalars (`theming/appearance.ts`), as a partial patch: only the keys that change. */
|
|
50
|
+
export const ThemePatchSchema = z
|
|
51
|
+
.object({
|
|
52
|
+
theme: z.enum(['wicked-dark', 'wicked-light']).optional(),
|
|
53
|
+
accent_h: z.number().min(0).max(360).optional(),
|
|
54
|
+
accent_s: z.number().min(0).max(100).optional(),
|
|
55
|
+
accent_l: z.number().min(0).max(100).optional(),
|
|
56
|
+
ambient: z.boolean().optional(),
|
|
57
|
+
ambient_still: z.boolean().optional(),
|
|
58
|
+
})
|
|
59
|
+
.strict()
|
|
60
|
+
.refine((p) => Object.keys(p).length > 0, 'a theme patch changes at least one key');
|
|
61
|
+
const EntryBlock = z.object({
|
|
62
|
+
entry: z.string().trim().min(1).max(80),
|
|
63
|
+
title: z.string().trim().max(200).optional(),
|
|
64
|
+
brief: z.string().max(8000).optional(),
|
|
65
|
+
url: z.string().trim().max(2048).optional(),
|
|
66
|
+
audience: z.string().max(400).optional(),
|
|
67
|
+
show: z.string().max(2000).optional(),
|
|
68
|
+
sources: SOURCES,
|
|
69
|
+
repos: REPOS,
|
|
70
|
+
reason: REASON,
|
|
71
|
+
});
|
|
72
|
+
const RefineBlock = z.object({
|
|
73
|
+
refine: z.string().trim().min(1).max(200),
|
|
74
|
+
ask: z.string().trim().min(1).max(4000),
|
|
75
|
+
touches: z.array(z.string().trim().min(1).max(128)).max(32).default([]),
|
|
76
|
+
sources: SOURCES,
|
|
77
|
+
reason: REASON,
|
|
78
|
+
});
|
|
79
|
+
const ThemeBlock = z.object({
|
|
80
|
+
theme: z.object({ name: z.string().trim().min(1).max(60), patch: ThemePatchSchema }),
|
|
81
|
+
reason: REASON,
|
|
82
|
+
});
|
|
83
|
+
/** One block body → its operation, or `null` (not JSON, not exactly one of `entry` / `refine` / `theme`, or a bad shape). */
|
|
84
|
+
export function parseMakeBlock(body) {
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(body);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
93
|
+
return null;
|
|
94
|
+
const o = raw;
|
|
95
|
+
const ops = ['entry', 'refine', 'theme'].filter((k) => o[k] !== undefined && o[k] !== null);
|
|
96
|
+
if (ops.length !== 1)
|
|
97
|
+
return null;
|
|
98
|
+
if (ops[0] === 'entry') {
|
|
99
|
+
const r = EntryBlock.safeParse(o);
|
|
100
|
+
return r.success ? { op: 'entry', ...r.data } : null;
|
|
101
|
+
}
|
|
102
|
+
if (ops[0] === 'refine') {
|
|
103
|
+
const r = RefineBlock.safeParse(o);
|
|
104
|
+
return r.success ? { op: 'refine', ...r.data } : null;
|
|
105
|
+
}
|
|
106
|
+
const r = ThemeBlock.safeParse(o);
|
|
107
|
+
return r.success ? { op: 'theme', ...r.data } : null;
|
|
108
|
+
}
|
|
109
|
+
/** The last block of a reply that parses (DES §4.1: the last valid block wins), or `null`. */
|
|
110
|
+
export function lastValidBlock(bodies) {
|
|
111
|
+
for (let i = bodies.length - 1; i >= 0; i -= 1) {
|
|
112
|
+
const parsed = parseMakeBlock(bodies[i]);
|
|
113
|
+
if (parsed !== null)
|
|
114
|
+
return parsed;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
// ── identity ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
/** The namespace of proposal run ids (a fixed random UUID; RFC 4122 §4.3 name-based ids under it). */
|
|
120
|
+
const PROPOSAL_NAMESPACE = '6f0b8a52-6d2e-4c4a-9a57-3f1f0c2b7e41';
|
|
121
|
+
/** RFC 4122 version-5 UUID of `name` under {@link PROPOSAL_NAMESPACE}: the run id a proposal's Go launches with. */
|
|
122
|
+
export function proposalRunId(pid) {
|
|
123
|
+
const ns = Buffer.from(PROPOSAL_NAMESPACE.replace(/-/g, ''), 'hex');
|
|
124
|
+
const h = createHash('sha1').update(ns).update(pid, 'utf8').digest();
|
|
125
|
+
h[6] = (h[6] & 0x0f) | 0x50;
|
|
126
|
+
h[8] = (h[8] & 0x3f) | 0x80;
|
|
127
|
+
const x = h.subarray(0, 16).toString('hex');
|
|
128
|
+
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
|
|
129
|
+
}
|
|
130
|
+
/** A create's document name, derived from the pid (stable across retries) with the title as a readable prefix. */
|
|
131
|
+
export function proposalDocName(pid, title) {
|
|
132
|
+
const slug = title
|
|
133
|
+
.toLowerCase()
|
|
134
|
+
.normalize('NFKD')
|
|
135
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
136
|
+
.replace(/^-+|-+$/g, '')
|
|
137
|
+
.slice(0, 48)
|
|
138
|
+
.replace(/-+$/g, '');
|
|
139
|
+
const tag = createHash('sha256').update(pid, 'utf8').digest('hex').slice(0, 8);
|
|
140
|
+
return `${slug === '' ? 'doc' : slug}-${tag}`;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Fold a chat's records into its proposals, by pid. A `proposal` record opens one (a newer pid supersedes the chat's
|
|
144
|
+
* open one; the same pid again replaces it, re-opening an interrupted `accepting` one whose run never launched); a
|
|
145
|
+
* `proposal_accepting` record marks it accepting; a `proposal_decided` record decides it.
|
|
146
|
+
*/
|
|
147
|
+
export function foldProposals(records) {
|
|
148
|
+
const out = new Map();
|
|
149
|
+
for (const r of records) {
|
|
150
|
+
if (r.kind === 'proposal') {
|
|
151
|
+
const pid = r.proposal.pid;
|
|
152
|
+
const held = out.get(pid);
|
|
153
|
+
if (held !== undefined && (held.state === 'decided' || held.state === 'superseded'))
|
|
154
|
+
continue;
|
|
155
|
+
// A re-resolution of an older proposal while a newer one exists never takes the chat back: it is superseded.
|
|
156
|
+
if (held !== undefined && [...out.keys()].indexOf(pid) < out.size - 1) {
|
|
157
|
+
held.state = 'superseded';
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// The newest proposal is the chat's one open proposal: a new pid, or a re-resolution of the newest (which replaces
|
|
161
|
+
// its frozen choices: the next Go uses the fresh card).
|
|
162
|
+
for (const [other, v] of out)
|
|
163
|
+
if (other !== pid && v.state === 'open')
|
|
164
|
+
v.state = 'superseded';
|
|
165
|
+
out.set(pid, { proposal: r.proposal, state: 'open', turnId: r.turnId });
|
|
166
|
+
}
|
|
167
|
+
else if (r.kind === 'proposal_accepting') {
|
|
168
|
+
const v = out.get(r.pid);
|
|
169
|
+
if (v === undefined || v.state === 'decided')
|
|
170
|
+
continue;
|
|
171
|
+
v.state = 'accepting';
|
|
172
|
+
v.accepting = { pid: r.pid, actor: r.actor, edits: r.edits, resolution: r.resolution, identity: r.identity };
|
|
173
|
+
}
|
|
174
|
+
else if (r.kind === 'proposal_decided') {
|
|
175
|
+
const v = out.get(r.pid);
|
|
176
|
+
if (v === undefined || v.state === 'decided')
|
|
177
|
+
continue;
|
|
178
|
+
v.state = 'decided';
|
|
179
|
+
v.decision = r.decision;
|
|
180
|
+
v.result = r.result;
|
|
181
|
+
v.decidedAt = r.at;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/** The wire view of a folded proposal (no internal fields): once accepted, the card shows what was accepted. */
|
|
187
|
+
export function viewOf(f) {
|
|
188
|
+
return {
|
|
189
|
+
proposal: f.accepting !== undefined && (f.state === 'accepting' || (f.state === 'decided' && f.decision === 'go')) ? f.accepting.resolution : f.proposal,
|
|
190
|
+
state: f.state,
|
|
191
|
+
...(f.decision !== undefined ? { decision: f.decision } : {}),
|
|
192
|
+
...(f.result !== undefined ? { result: f.result } : {}),
|
|
193
|
+
...(f.decidedAt !== undefined ? { decidedAt: f.decidedAt } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** Serialize work per key: a second caller waits for the first, then runs. */
|
|
197
|
+
class KeyedLock {
|
|
198
|
+
tails = new Map();
|
|
199
|
+
run(key, fn) {
|
|
200
|
+
const prev = this.tails.get(key) ?? Promise.resolve();
|
|
201
|
+
const next = prev.then(fn, fn);
|
|
202
|
+
const tail = next.then(() => undefined, () => undefined);
|
|
203
|
+
this.tails.set(key, tail);
|
|
204
|
+
void tail.then(() => {
|
|
205
|
+
if (this.tails.get(key) === tail)
|
|
206
|
+
this.tails.delete(key);
|
|
207
|
+
});
|
|
208
|
+
return next;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* DES §4.3 step 3: ONE launcher per run id. `launchOnce` takes the run id's lock, asks the operation's launcher whether
|
|
213
|
+
* that run exists (adopt), and starts it only if not — so a retry, a second tab and a late bus trigger (A3 calls this
|
|
214
|
+
* from the `doc.created` path too) can never both launch.
|
|
215
|
+
*/
|
|
216
|
+
export class LaunchOnceRegistry {
|
|
217
|
+
dispatchers = new Map();
|
|
218
|
+
lock = new KeyedLock();
|
|
219
|
+
/** Runs this daemon started per artifact (`<mount>/<doc>`): a document is busy while one is live, whichever chat
|
|
220
|
+
* started it, even before the engine's run list names its document. */
|
|
221
|
+
byArtifact = new Map();
|
|
222
|
+
register(kind, dispatcher) {
|
|
223
|
+
this.dispatchers.set(kind, dispatcher);
|
|
224
|
+
}
|
|
225
|
+
has(kind) {
|
|
226
|
+
return this.dispatchers.has(kind);
|
|
227
|
+
}
|
|
228
|
+
/** The run this identity names, when it exists (adopted, never started again), with whether a stub took it. */
|
|
229
|
+
async find(kind, identity) {
|
|
230
|
+
const d = this.dispatchers.get(kind);
|
|
231
|
+
if (d === undefined || !(await d.find(identity)))
|
|
232
|
+
return null;
|
|
233
|
+
return { stub: d.stub === true };
|
|
234
|
+
}
|
|
235
|
+
async live(kind, runId) {
|
|
236
|
+
const d = this.dispatchers.get(kind);
|
|
237
|
+
return d?.live === undefined ? false : d.live(runId);
|
|
238
|
+
}
|
|
239
|
+
/** Whether a run this daemon started for the artifact is still live, or a launcher's durable record says an attempt at
|
|
240
|
+
* it has no outcome yet (A3: across a restart too). */
|
|
241
|
+
async liveForArtifact(artifactRef) {
|
|
242
|
+
for (const r of this.byArtifact.get(artifactRef) ?? [])
|
|
243
|
+
if (await this.live(r.kind, r.runId))
|
|
244
|
+
return true;
|
|
245
|
+
for (const d of new Set(this.dispatchers.values()))
|
|
246
|
+
if (d.docBusy !== undefined && (await d.docBusy(artifactRef)))
|
|
247
|
+
return true;
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
launchOnce(dispatch) {
|
|
251
|
+
const d = this.dispatchers.get(dispatch.kind);
|
|
252
|
+
if (d === undefined)
|
|
253
|
+
return Promise.reject(new Error(`nothing can start a ${dispatch.kind} on this daemon yet`));
|
|
254
|
+
return this.lock.run(dispatch.identity.runId, async () => {
|
|
255
|
+
const base = { runId: dispatch.identity.runId, artifactRef: dispatch.identity.artifactRef, stub: d.stub === true };
|
|
256
|
+
if (await d.find(dispatch.identity))
|
|
257
|
+
return { ...base, adopted: true };
|
|
258
|
+
await d.launch(dispatch);
|
|
259
|
+
const ref = dispatch.identity.artifactRef;
|
|
260
|
+
if (ref !== null)
|
|
261
|
+
this.byArtifact.set(ref, [...(this.byArtifact.get(ref) ?? []).slice(-15), { kind: dispatch.kind, runId: dispatch.identity.runId }]);
|
|
262
|
+
return { ...base, adopted: false };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The TEST configuration's launcher (DES §13 A2): records each dispatch as one JSON line and starts nothing. Its own
|
|
268
|
+
* record is what `find` reads, so a retry adopts the recorded dispatch exactly as a real launcher adopts an engine run.
|
|
269
|
+
*/
|
|
270
|
+
export class RecordingStubDispatcher {
|
|
271
|
+
file;
|
|
272
|
+
now;
|
|
273
|
+
stub = true;
|
|
274
|
+
constructor(file, now = Date.now) {
|
|
275
|
+
this.file = file;
|
|
276
|
+
this.now = now;
|
|
277
|
+
}
|
|
278
|
+
/** Every recorded dispatch, in order. */
|
|
279
|
+
rows() {
|
|
280
|
+
if (!existsSync(this.file))
|
|
281
|
+
return [];
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const line of readFileSync(this.file, 'utf8').split('\n')) {
|
|
284
|
+
if (line.trim() === '')
|
|
285
|
+
continue;
|
|
286
|
+
try {
|
|
287
|
+
out.push(JSON.parse(line));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
/* a torn last line */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
async find(identity) {
|
|
296
|
+
return this.rows().some((r) => r.runId === identity.runId);
|
|
297
|
+
}
|
|
298
|
+
async launch(d) {
|
|
299
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
300
|
+
const fresh = !existsSync(this.file);
|
|
301
|
+
appendFileSync(this.file, `${fresh ? '' : tornTailPrefix(this.file)}${JSON.stringify({ at: this.now(), kind: d.kind, pid: d.pid, chatId: d.chatId, runId: d.identity.runId, artifactRef: d.identity.artifactRef, actor: d.actor.id, title: d.resolution.what.title })}\n`, { mode: 0o600 });
|
|
302
|
+
if (fresh && process.platform !== 'win32')
|
|
303
|
+
chmodSync(this.file, 0o600);
|
|
304
|
+
}
|
|
305
|
+
async live() {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// ── cost ───────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
310
|
+
/**
|
|
311
|
+
* A completed run's cost in dollars when it is wholly known, else `null` (DES §4.2: "a dollar figure only counts runs
|
|
312
|
+
* whose every step reported a cost"). Every attempt the engine dispatched for an agent step (a tool step is no agent's)
|
|
313
|
+
* must have reported a finite, priced usage frame; an unpriced frame, or a dispatched attempt with none (a retry on a
|
|
314
|
+
* seat that reports nothing), makes the run's cost unknown.
|
|
315
|
+
*/
|
|
316
|
+
export function runCostFromEvents(view, events) {
|
|
317
|
+
const agentOrds = view.units
|
|
318
|
+
.filter((u) => u.routing?.method !== 'tool' && u.assigned_cli !== null)
|
|
319
|
+
.map((u) => u.ord);
|
|
320
|
+
if (agentOrds.length === 0)
|
|
321
|
+
return null;
|
|
322
|
+
const dispatched = new Set();
|
|
323
|
+
const priced = new Set();
|
|
324
|
+
const byOrd = new Map();
|
|
325
|
+
for (const e of events) {
|
|
326
|
+
const ord = e['ord'] ?? e['unitOrd'];
|
|
327
|
+
if (typeof ord !== 'number' || !agentOrds.includes(ord))
|
|
328
|
+
continue;
|
|
329
|
+
const attempt = typeof e['attempt'] === 'number' ? e['attempt'] : 0;
|
|
330
|
+
if (e['type'] === 'unitDispatched')
|
|
331
|
+
dispatched.add(`${ord}:${attempt}`);
|
|
332
|
+
if (e['type'] !== 'cliUsage')
|
|
333
|
+
continue;
|
|
334
|
+
const cost = e['costUsd'];
|
|
335
|
+
if (typeof cost !== 'number' || !Number.isFinite(cost) || cost < 0)
|
|
336
|
+
return null;
|
|
337
|
+
priced.add(`${ord}:${attempt}`);
|
|
338
|
+
byOrd.set(ord, (byOrd.get(ord) ?? 0) + cost);
|
|
339
|
+
}
|
|
340
|
+
if (agentOrds.some((o) => !byOrd.has(o)))
|
|
341
|
+
return null;
|
|
342
|
+
if ([...dispatched].some((k) => !priced.has(k)))
|
|
343
|
+
return null;
|
|
344
|
+
// The unit's durable attempt counter outranks a log that may have lost an attempt (a crash): EVERY attempt up to it
|
|
345
|
+
// must be priced.
|
|
346
|
+
for (const u of view.units) {
|
|
347
|
+
const last = u.last_attempt;
|
|
348
|
+
if (!agentOrds.includes(u.ord) || typeof last !== 'number' || !Number.isInteger(last) || last < 0)
|
|
349
|
+
continue;
|
|
350
|
+
for (let a = 0; a <= last; a += 1)
|
|
351
|
+
if (!priced.has(`${u.ord}:${a}`))
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
return agentOrds.reduce((sum, o) => sum + (byOrd.get(o) ?? 0), 0);
|
|
355
|
+
}
|
|
356
|
+
// ── resolution ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
357
|
+
/** The workflow each operation's run is. */
|
|
358
|
+
export const PROPOSAL_WORKFLOWS = {
|
|
359
|
+
create: 'interactive-draft',
|
|
360
|
+
refine: 'interactive-chat',
|
|
361
|
+
demo: 'demo',
|
|
362
|
+
};
|
|
363
|
+
const STOPS = {
|
|
364
|
+
create: 'Stops for you if the check fails. The plan or an escalation may also ask.',
|
|
365
|
+
refine: 'Stops for you if the check fails. The plan or an escalation may also ask.',
|
|
366
|
+
demo: 'Stops for you after the script and after the review.',
|
|
367
|
+
};
|
|
368
|
+
const STYLE_LABEL = { ppt: 'Slides', web: 'Web page', doc: 'Document', brochure: 'Brochure' };
|
|
369
|
+
const NOUN = {
|
|
370
|
+
create: ['document', 'documents'],
|
|
371
|
+
refine: ['revision', 'revisions'],
|
|
372
|
+
demo: ['demo video', 'demo videos'],
|
|
373
|
+
};
|
|
374
|
+
const TERMINAL = new Set(['completed', 'cancelled', 'failed']);
|
|
375
|
+
const STATUS_WORDS = {
|
|
376
|
+
completed: 'finished',
|
|
377
|
+
cancelled: 'stopped',
|
|
378
|
+
failed: 'failed',
|
|
379
|
+
awaiting_human: 'waiting for you',
|
|
380
|
+
};
|
|
381
|
+
/** Completed runs read for a time median, newest first. */
|
|
382
|
+
const MEDIAN_RUNS = 20;
|
|
383
|
+
/** Of those, the newest whose costs are read (each needs the run's event log). */
|
|
384
|
+
const COST_RUNS = 10;
|
|
385
|
+
/** Runs a dollar median needs (DES §4.2). */
|
|
386
|
+
const COST_MIN_RUNS = 3;
|
|
387
|
+
/** The project a mount names (`null` for Unfiled). */
|
|
388
|
+
const projectOfMount = (mount) => (mount === UNFILED_MOUNT ? null : mount);
|
|
389
|
+
/** The mount a run is filed in. */
|
|
390
|
+
function runMount(view) {
|
|
391
|
+
const p = view.session.project_id;
|
|
392
|
+
return p === undefined || p === null || p === '' || p === 'default' ? UNFILED_MOUNT : p;
|
|
393
|
+
}
|
|
394
|
+
const isLive = (v) => !TERMINAL.has(String(v.session.status));
|
|
395
|
+
const workflowOf = (v) => {
|
|
396
|
+
const s = v.session;
|
|
397
|
+
return s.run_identity?.name ?? s.workflow_id ?? null;
|
|
398
|
+
};
|
|
399
|
+
const secondsOf = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
400
|
+
function median(xs) {
|
|
401
|
+
if (xs.length === 0)
|
|
402
|
+
return null;
|
|
403
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
404
|
+
const m = Math.floor(s.length / 2);
|
|
405
|
+
return s.length % 2 === 1 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
406
|
+
}
|
|
407
|
+
const clock = (ms) => new Date(ms).toISOString().slice(11, 16);
|
|
408
|
+
const humanDoc = (doc) => doc.replace(/-/g, ' ');
|
|
409
|
+
/** What the turn's seats were given (the turn's `context` record), else the latest one before the turn — never a
|
|
410
|
+
* later turn's. */
|
|
411
|
+
function bindingOf(records, turnId) {
|
|
412
|
+
const own = records.find((r) => r.kind === 'context' && r.turnId === turnId);
|
|
413
|
+
if (own !== undefined)
|
|
414
|
+
return { binding: own, turnHas: true };
|
|
415
|
+
let latest = null;
|
|
416
|
+
for (const r of records) {
|
|
417
|
+
if (r.turnId === turnId)
|
|
418
|
+
break;
|
|
419
|
+
if (r.kind === 'context')
|
|
420
|
+
latest = r;
|
|
421
|
+
}
|
|
422
|
+
return { binding: latest, turnHas: false };
|
|
423
|
+
}
|
|
424
|
+
/** Hosts the demo recorder can reach (DES §10: a loopback-only jail). The seat's instructions name the same three
|
|
425
|
+
* (`chat-scope.ts`; a test keeps them equal). */
|
|
426
|
+
export const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
427
|
+
/** Every http(s) address in the person's words, whole (trailing sentence punctuation dropped). */
|
|
428
|
+
function personUrls(words) {
|
|
429
|
+
const out = new Set();
|
|
430
|
+
for (const m of words.matchAll(/https?:\/\/[^\s"'<>`]+/gu)) {
|
|
431
|
+
// Sentence punctuation after an address is not part of it; a bracketed IPv6 host's own ']' is.
|
|
432
|
+
let url = m[0].replace(/[.,;:!?)}]+$/u, '');
|
|
433
|
+
if (url.endsWith(']') && !/^https?:\/\/\[[^\]]*\]$/u.test(url))
|
|
434
|
+
url = url.replace(/\]+$/u, '');
|
|
435
|
+
out.add(url);
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* The person's own words in this chat, up to and including the turn: only `user` records the message route marked as
|
|
441
|
+
* sent by the chat's owner, a person (`owner: true`). A message from an agent's or another actor's token never counts
|
|
442
|
+
* as the person's (a demo address, a document they named).
|
|
443
|
+
*/
|
|
444
|
+
function personWords(records, turnId) {
|
|
445
|
+
const out = [];
|
|
446
|
+
for (const r of records) {
|
|
447
|
+
if (r.kind === 'user') {
|
|
448
|
+
if (r.owner === true)
|
|
449
|
+
out.push(r.text);
|
|
450
|
+
if (r.turnId === turnId)
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return out.join('\n');
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Whether the person's own words name a document by its whole ref (`mount/doc`, or `doc:mount/doc`; studio's `default`
|
|
458
|
+
* mount alias for an Unfiled one): a longer name or path (`mount/doc-2`, `mount/doc_x`, `mount/doc/notes`,
|
|
459
|
+
* `mount/doc.html`) is not it. Sentence punctuation after it is.
|
|
460
|
+
*/
|
|
461
|
+
function wordsNameRef(words, ref) {
|
|
462
|
+
const spellings = ref.startsWith(`${UNFILED_MOUNT}/`) ? [ref, `default/${ref.slice(UNFILED_MOUNT.length + 1)}`] : [ref];
|
|
463
|
+
return spellings.some((r) => {
|
|
464
|
+
const esc = r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
465
|
+
return new RegExp(`(^|[^A-Za-z0-9._/-])(?:doc:)?${esc}(?![A-Za-z0-9_-]|[./][A-Za-z0-9_-])`, 'u').test(words);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/** The title an earlier turn's `context` record gave the document, when one did (the chat had it open). */
|
|
469
|
+
function titleSeen(records, ref, turnId) {
|
|
470
|
+
let title = null;
|
|
471
|
+
for (const r of records) {
|
|
472
|
+
if (r.kind === 'context' && r.artifact?.ref === ref)
|
|
473
|
+
title = r.artifact.title;
|
|
474
|
+
if (r.kind === 'user' && r.turnId === turnId)
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
return title;
|
|
478
|
+
}
|
|
479
|
+
/** Why an edit was refused, as the 400 says it. */
|
|
480
|
+
class EditRefusal extends Error {
|
|
481
|
+
}
|
|
482
|
+
/** The one block a person can lift from the card (Change something → two helpers); every other block is the record's. */
|
|
483
|
+
const NEEDS_HELPER = 'Needs a second helper to check it';
|
|
484
|
+
export class ChatProposals {
|
|
485
|
+
deps;
|
|
486
|
+
lock = new KeyedLock();
|
|
487
|
+
/** Per chat: resolutions publish in the order their replies were folded (a slow one never lands after a newer one). */
|
|
488
|
+
order = new KeyedLock();
|
|
489
|
+
pending = new Set();
|
|
490
|
+
costs = new Map();
|
|
491
|
+
now;
|
|
492
|
+
log;
|
|
493
|
+
constructor(deps) {
|
|
494
|
+
this.deps = deps;
|
|
495
|
+
this.now = deps.now ?? Date.now;
|
|
496
|
+
this.log = deps.log ?? (() => undefined);
|
|
497
|
+
}
|
|
498
|
+
/** What a seat may be told it can make now (`chat-scope.ts`'s per-message digest). */
|
|
499
|
+
digest() {
|
|
500
|
+
const f = this.deps.flags();
|
|
501
|
+
const on = f.proposals;
|
|
502
|
+
const entries = on
|
|
503
|
+
? this.deps.catalog.flatMap((c) => {
|
|
504
|
+
if (c.status === 'coming_soon')
|
|
505
|
+
return [];
|
|
506
|
+
if (c.launch.kind === 'document' && f.create)
|
|
507
|
+
return [{ id: c.id, title: c.title ?? c.id, kind: 'document', outcome: c.outcome }];
|
|
508
|
+
if (c.launch.kind === 'demo' && f.demo)
|
|
509
|
+
return [{ id: c.id, title: c.title ?? c.id, kind: 'demo', outcome: c.outcome }];
|
|
510
|
+
return [];
|
|
511
|
+
})
|
|
512
|
+
: [];
|
|
513
|
+
// A create or demo with no catalog entry to name is not on (the seat is told nothing it could propose).
|
|
514
|
+
return {
|
|
515
|
+
create: on && f.create && entries.some((e) => e.kind === 'document'),
|
|
516
|
+
refine: on && f.refine,
|
|
517
|
+
theme: on && f.theme,
|
|
518
|
+
demo: on && f.demo && entries.some((e) => e.kind === 'demo'),
|
|
519
|
+
entries,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
/** Resolutions still in flight (tests wait on them). */
|
|
523
|
+
async settled() {
|
|
524
|
+
while (this.pending.size > 0)
|
|
525
|
+
await Promise.all([...this.pending]);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* A seat's reply was folded with the `wicked-make` bodies cut from it. Resolution reads crew's records (it awaits),
|
|
529
|
+
* so it runs off the fold; the record is appended only if the chat is still the same instance.
|
|
530
|
+
*/
|
|
531
|
+
onReply(frame) {
|
|
532
|
+
if (frame.blocks.length === 0 || frame.turnId === undefined || !frame.ok)
|
|
533
|
+
return;
|
|
534
|
+
if (!this.deps.flags().proposals)
|
|
535
|
+
return;
|
|
536
|
+
const block = lastValidBlock(frame.blocks);
|
|
537
|
+
if (block === null) {
|
|
538
|
+
this.log(`[proposals] chat ${frame.chat}: ${frame.blocks.length} wicked-make block(s) from ${frame.cliKey}, none valid — no card`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const instance = this.deps.instanceOf(frame.chat);
|
|
542
|
+
const turnId = frame.turnId;
|
|
543
|
+
// Chained per chat: a proposal resolves after every earlier reply's, so a slow resolution can never be appended
|
|
544
|
+
// after (and so supersede) a newer turn's card.
|
|
545
|
+
const job = this.order.run(`chat\u0000${frame.chat}`, async () => {
|
|
546
|
+
// Nothing of a chat that ended and reused its id is read for this (older) reply.
|
|
547
|
+
if (this.deps.instanceOf(frame.chat) !== instance)
|
|
548
|
+
return;
|
|
549
|
+
const pid = `${frame.chat}:${turnId}`;
|
|
550
|
+
const has = () => {
|
|
551
|
+
const rs = this.deps.records(frame.chat);
|
|
552
|
+
// One proposal per turn (a second seat answering the same turn never replaces it), and none for a turn once a
|
|
553
|
+
// later turn has its own card (a slow seat's late reply never takes the chat back).
|
|
554
|
+
const order = rs.filter((r) => r.kind === 'user').map((r) => r.turnId);
|
|
555
|
+
const mine = order.indexOf(turnId);
|
|
556
|
+
// A reply can fold before its own turn's record is written (the route records it after the engine accepted the
|
|
557
|
+
// message): such a turn is the newest, and only its own card suppresses it.
|
|
558
|
+
return rs.some((r) => r.kind === 'proposal' && (r.proposal.pid === pid || (mine !== -1 && r.proposal.turnId !== null && order.indexOf(r.proposal.turnId) > mine)));
|
|
559
|
+
};
|
|
560
|
+
if (has())
|
|
561
|
+
return;
|
|
562
|
+
// Give the route a moment to record the turn's own words and context (they authorize what the block names).
|
|
563
|
+
for (let i = 0; i < 25 && !this.deps.records(frame.chat).some((r) => r.kind === 'user' && r.turnId === turnId); i += 1) {
|
|
564
|
+
await new Promise((res) => setTimeout(res, 200));
|
|
565
|
+
}
|
|
566
|
+
if (this.deps.instanceOf(frame.chat) !== instance || has())
|
|
567
|
+
return;
|
|
568
|
+
const proposal = await this.resolve({ chatId: frame.chat, turnId, seat: frame.cliKey, block, records: this.deps.records(frame.chat) });
|
|
569
|
+
if (proposal === null)
|
|
570
|
+
return;
|
|
571
|
+
// Published under the chat's lock, so it never lands between an accept's checks and its record.
|
|
572
|
+
await this.lock.run(`chat\u0000${instance ?? frame.chat}`, async () => {
|
|
573
|
+
if (!has())
|
|
574
|
+
this.write(frame.chat, { at: this.now(), turnId, kind: 'proposal', proposal }, instance);
|
|
575
|
+
});
|
|
576
|
+
})
|
|
577
|
+
.catch((err) => this.log(`[proposals] chat ${frame.chat}: turn ${turnId}'s proposal could not be resolved: ${err instanceof Error ? err.message : String(err)}`))
|
|
578
|
+
.finally(() => this.pending.delete(job));
|
|
579
|
+
this.pending.add(job);
|
|
580
|
+
}
|
|
581
|
+
/** Append a record and send it on `/ws` — only while the chat is still the instance the caller acted on. */
|
|
582
|
+
write(chatId, record, instance) {
|
|
583
|
+
if (this.deps.instanceOf(chatId) !== instance) {
|
|
584
|
+
this.log(`[proposals] chat ${chatId} ended and its id was reused — ${record.kind} not recorded on the new chat`);
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
if (!this.deps.append(chatId, record)) {
|
|
588
|
+
this.log(`[proposals] chat ${chatId}: no transcript to record ${record.kind} on`);
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
const projectId = this.deps.projectOf(chatId);
|
|
592
|
+
const frame = { type: 'chatProposal', chat: chatId, turn_id: record.turnId, record, ...(projectId !== null ? { project_id: projectId } : {}) };
|
|
593
|
+
this.deps.broadcast(frame);
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The block → a card's facts. `null` when the operation is not advertised (the seat was never told about it) or the
|
|
598
|
+
* chat has ended: no card. Everything else resolves, with what cannot be resolved struck or blocking Go.
|
|
599
|
+
*/
|
|
600
|
+
async resolve(input) {
|
|
601
|
+
const { chatId, turnId, seat, block, records } = input;
|
|
602
|
+
const flags = this.deps.flags();
|
|
603
|
+
const chat = this.deps.chat(chatId);
|
|
604
|
+
if (chat === null)
|
|
605
|
+
return null;
|
|
606
|
+
if (chat.analysis) {
|
|
607
|
+
this.log(`[proposals] chat ${chatId} is an analysis: it proposes nothing — no card`);
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
const pid = `${chatId}:${turnId}`;
|
|
611
|
+
const words = personWords(records, turnId);
|
|
612
|
+
const { binding, turnHas } = bindingOf(records, turnId);
|
|
613
|
+
const blocked = [];
|
|
614
|
+
const filedIn = async (projectId) => ({
|
|
615
|
+
projectId,
|
|
616
|
+
label: projectId === null ? 'Not in a project · this chat' : ((await this.deps.projectName(projectId)) ?? projectId),
|
|
617
|
+
});
|
|
618
|
+
if (block.op === 'theme') {
|
|
619
|
+
if (!flags.theme)
|
|
620
|
+
return this.refused(chatId, 'theme');
|
|
621
|
+
const patch = block.theme.patch;
|
|
622
|
+
return {
|
|
623
|
+
pid, chatId, turnId, kind: 'theme', seat,
|
|
624
|
+
what: { entry: null, title: block.theme.name, label: `Theme · ${block.theme.name}`, outcome: 'A studio appearance you preview, then apply. Nothing else changes.' },
|
|
625
|
+
brief: null,
|
|
626
|
+
reason: block.reason ?? null,
|
|
627
|
+
sources: [],
|
|
628
|
+
repos: [],
|
|
629
|
+
helpers: null,
|
|
630
|
+
time: null,
|
|
631
|
+
stops: null,
|
|
632
|
+
filedIn: { projectId: null, label: 'Your studio settings' },
|
|
633
|
+
theme: { name: block.theme.name, patch, changes: Object.keys(patch) },
|
|
634
|
+
blocked: null,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (block.op === 'refine') {
|
|
638
|
+
if (!flags.refine)
|
|
639
|
+
return this.refused(chatId, 'refine');
|
|
640
|
+
const ref = parseArtifactRef(block.refine);
|
|
641
|
+
let target;
|
|
642
|
+
let projectId = chat.projectId;
|
|
643
|
+
if (ref === null) {
|
|
644
|
+
blocked.push(`“${block.refine}” is not a document studio knows`);
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
const open = binding?.artifact?.ref === ref.ref;
|
|
648
|
+
const named = open || (binding?.named.docs ?? []).includes(ref.ref) || wordsNameRef(words, ref.ref);
|
|
649
|
+
const mine = chatMount(chat.projectId);
|
|
650
|
+
const title = open && binding?.artifact !== undefined ? binding.artifact.title : titleSeen(records, ref.ref, turnId) ?? humanDoc(ref.doc);
|
|
651
|
+
// Authorized before anything of it is read: an unnamed or out-of-scope document is never resolved.
|
|
652
|
+
if (!named)
|
|
653
|
+
blocked.push(`${title} · not open or named in this chat`);
|
|
654
|
+
else if (mine !== null && ref.mount !== mine)
|
|
655
|
+
blocked.push(`${title} · not in this chat's project`);
|
|
656
|
+
else {
|
|
657
|
+
const root = await this.deps.docsRoot(ref.projectId);
|
|
658
|
+
const head = root === null ? null : artifactHead(root, ref.doc);
|
|
659
|
+
if (head === null)
|
|
660
|
+
blocked.push(`${title} · no such document`);
|
|
661
|
+
else {
|
|
662
|
+
target = { ref: ref.ref, title, base: head };
|
|
663
|
+
projectId = projectOfMount(ref.mount);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const sources = await this.resolveSources(block.sources, { chatId, turnId, records, binding, turnHas, chat, words, personNamed: false });
|
|
668
|
+
const helpers = this.helpers(chat);
|
|
669
|
+
if (helpers === null)
|
|
670
|
+
blocked.push(NEEDS_HELPER);
|
|
671
|
+
const title = target?.title ?? (ref !== null ? humanDoc(ref.doc) : block.refine);
|
|
672
|
+
return {
|
|
673
|
+
pid, chatId, turnId, kind: 'refine', seat,
|
|
674
|
+
what: { entry: null, title, label: `Change · ${title}`, outcome: 'A new version of the document. Your own edits are kept.' },
|
|
675
|
+
brief: block.ask,
|
|
676
|
+
reason: block.reason ?? null,
|
|
677
|
+
sources,
|
|
678
|
+
repos: [],
|
|
679
|
+
helpers,
|
|
680
|
+
time: await this.timeOf('refine', null, projectId),
|
|
681
|
+
stops: STOPS.refine,
|
|
682
|
+
filedIn: await filedIn(projectId),
|
|
683
|
+
...(target !== undefined ? { target } : {}),
|
|
684
|
+
...(block.touches.length > 0 ? { touches: block.touches } : {}),
|
|
685
|
+
blocked: blocked[0] ?? null,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
// An entry: a document or a demo from the catalog.
|
|
689
|
+
const seed = this.deps.catalog.find((c) => c.id === block.entry && (c.launch.kind === 'document' || c.launch.kind === 'demo'));
|
|
690
|
+
const kind = seed?.launch.kind === 'demo' || block.entry === 'demo-video' ? 'demo' : 'create';
|
|
691
|
+
if (!flags[kind])
|
|
692
|
+
return this.refused(chatId, kind);
|
|
693
|
+
if (seed === undefined || seed.status === 'coming_soon')
|
|
694
|
+
blocked.push(`studio has no job called “${block.entry}”`);
|
|
695
|
+
const style = seed !== undefined && seed.launch.kind === 'document' ? seed.launch.style : null;
|
|
696
|
+
const title = block.title !== undefined && block.title !== '' ? block.title : seed?.title ?? block.entry;
|
|
697
|
+
const repos = [];
|
|
698
|
+
const repoRefs = [];
|
|
699
|
+
for (const want of block.repos) {
|
|
700
|
+
const hits = chat.repos.filter((r) => r.name === want || r.id === want);
|
|
701
|
+
if (hits.length === 0)
|
|
702
|
+
blocked.push(`${want} isn't in this chat's scope`);
|
|
703
|
+
else if (hits.length > 1)
|
|
704
|
+
blocked.push(`${want} names ${hits.length} repositories in this chat's scope (name it by id)`);
|
|
705
|
+
else if (!repoRefs.includes(hits[0].id)) {
|
|
706
|
+
repos.push(hits[0].name);
|
|
707
|
+
repoRefs.push(hits[0].id);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// A3 (§5.2): a chat-made document is grounded on the chat's scope. A block that names none, in a chat scoped to ONE
|
|
711
|
+
// repository, is about that one; with several, nothing is guessed (the card says what it is about).
|
|
712
|
+
if (kind === 'create' && block.repos.length === 0 && chat.repos.length === 1) {
|
|
713
|
+
repos.push(chat.repos[0].name);
|
|
714
|
+
repoRefs.push(chat.repos[0].id);
|
|
715
|
+
}
|
|
716
|
+
let demo;
|
|
717
|
+
if (kind === 'demo') {
|
|
718
|
+
const url = block.url ?? '';
|
|
719
|
+
let ok = false;
|
|
720
|
+
try {
|
|
721
|
+
const u = new URL(url);
|
|
722
|
+
ok = u.protocol === 'http:' || u.protocol === 'https:';
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
ok = false;
|
|
726
|
+
}
|
|
727
|
+
// DES §4.3 / §10: the address must come from the person, never from the model.
|
|
728
|
+
if (url === '' || !ok)
|
|
729
|
+
blocked.push('Needs the address of the running app');
|
|
730
|
+
// Whole addresses only: a prefix, a substring or an address inside a query string is not one they gave.
|
|
731
|
+
else if (!personUrls(words).has(url))
|
|
732
|
+
blocked.push('The app address must come from you: say it in the chat');
|
|
733
|
+
// DES §10 (A8's build note): the recorder reaches only an app on this machine.
|
|
734
|
+
else if (!LOCAL_HOSTS.has(new URL(url).hostname))
|
|
735
|
+
blocked.push('The recorder reaches only an app running on this machine (localhost)');
|
|
736
|
+
demo = { url, audience: block.audience ?? null, show: block.show ?? null };
|
|
737
|
+
}
|
|
738
|
+
const sources = kind === 'create' ? await this.resolveSources(block.sources, { chatId, turnId, records, binding, turnHas, chat, words, personNamed: false }) : [];
|
|
739
|
+
const helpers = this.helpers(chat);
|
|
740
|
+
if (helpers === null)
|
|
741
|
+
blocked.push(NEEDS_HELPER);
|
|
742
|
+
return {
|
|
743
|
+
pid, chatId, turnId, kind, seat,
|
|
744
|
+
what: {
|
|
745
|
+
entry: seed?.id ?? block.entry,
|
|
746
|
+
title,
|
|
747
|
+
label: `${kind === 'demo' ? 'Demo video' : (style !== null ? STYLE_LABEL[style] : undefined) ?? seed?.title ?? 'Document'} · ${title}`,
|
|
748
|
+
outcome: seed?.outcome ?? null,
|
|
749
|
+
},
|
|
750
|
+
brief: kind === 'demo' ? (block.show ?? null) : (block.brief ?? null),
|
|
751
|
+
reason: block.reason ?? null,
|
|
752
|
+
sources,
|
|
753
|
+
repos,
|
|
754
|
+
...(repoRefs.length > 0 ? { repoRefs } : {}),
|
|
755
|
+
helpers,
|
|
756
|
+
time: await this.timeOf(kind, seed ?? null, chat.projectId),
|
|
757
|
+
stops: STOPS[kind],
|
|
758
|
+
filedIn: await filedIn(chat.projectId),
|
|
759
|
+
...(demo !== undefined ? { demo } : {}),
|
|
760
|
+
blocked: blocked[0] ?? null,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
refused(chatId, kind) {
|
|
764
|
+
this.log(`[proposals] chat ${chatId}: a ${kind} block, but ${kind} is not advertised on this daemon — no card`);
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
/** The two helpers (DES §4.2): the lead seat, then another of the chat's seats, else the roster's next. */
|
|
768
|
+
helpers(chat, chosen) {
|
|
769
|
+
const roster = this.deps.rosterEligible();
|
|
770
|
+
const can = new Set(roster.map((s) => s.key));
|
|
771
|
+
const name = (k) => roster.find((s) => s.key === k)?.name ?? k;
|
|
772
|
+
let seats;
|
|
773
|
+
if (chosen !== undefined) {
|
|
774
|
+
seats = [...new Set(chosen)];
|
|
775
|
+
if (seats.length !== 2 || seats.some((k) => !can.has(k)))
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
const order = [chat.lead, ...chat.seats, ...roster.map((s) => s.key)].filter((k) => k !== null && can.has(k));
|
|
780
|
+
seats = [...new Set(order)].slice(0, 2);
|
|
781
|
+
if (seats.length < 2)
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
return { seats, label: `${name(seats[0])} and ${name(seats[1])}: one writes, the other checks` };
|
|
785
|
+
}
|
|
786
|
+
/** Time and cost: the catalog's band, the workflow's steps, and medians over completed runs here. */
|
|
787
|
+
async timeOf(kind, seed, projectId) {
|
|
788
|
+
const workflow = PROPOSAL_WORKFLOWS[kind];
|
|
789
|
+
const minutes = seed?.typicalMinutes ?? null;
|
|
790
|
+
const cost = seed?.cost ?? null;
|
|
791
|
+
const steps = await this.deps.workflowSteps(workflow, projectId).catch(() => null);
|
|
792
|
+
const done = (await this.deps.runs())
|
|
793
|
+
.filter((v) => String(v.session.status) === 'completed' && workflowOf(v) === workflow)
|
|
794
|
+
.map((v) => ({ v, start: secondsOf(v.session.created_at), end: secondsOf(v.session.ended_at) }))
|
|
795
|
+
.filter((r) => r.start !== null && r.end !== null && r.end >= r.start)
|
|
796
|
+
.sort((a, b) => b.start - a.start)
|
|
797
|
+
.slice(0, MEDIAN_RUNS);
|
|
798
|
+
let med = null;
|
|
799
|
+
if (done.length > 0) {
|
|
800
|
+
const mins = median(done.map((r) => (r.end - r.start) / 60));
|
|
801
|
+
const costs = [];
|
|
802
|
+
for (const r of done.slice(0, COST_RUNS)) {
|
|
803
|
+
const id = r.v.session.id;
|
|
804
|
+
// A completed run's cost does not change: cached. A failed read is not (it is retried next time).
|
|
805
|
+
let c = this.costs.get(id);
|
|
806
|
+
if (c === undefined) {
|
|
807
|
+
try {
|
|
808
|
+
c = await this.deps.runCostUsd(r.v);
|
|
809
|
+
this.costs.set(id, c);
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
c = null;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (typeof c === 'number' && Number.isFinite(c))
|
|
816
|
+
costs.push(c);
|
|
817
|
+
}
|
|
818
|
+
med = {
|
|
819
|
+
runs: done.length,
|
|
820
|
+
minutes: Math.round(mins),
|
|
821
|
+
costUsd: costs.length >= COST_MIN_RUNS ? Math.round(median(costs) * 100) / 100 : null,
|
|
822
|
+
costRuns: costs.length,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
const parts = [];
|
|
826
|
+
if (minutes !== null)
|
|
827
|
+
parts.push(`About ${minutes[0]}–${minutes[1]} min`);
|
|
828
|
+
if (steps !== null)
|
|
829
|
+
parts.push(`${steps} step${steps === 1 ? '' : 's'}`);
|
|
830
|
+
if (med !== null) {
|
|
831
|
+
const [one, many] = NOUN[kind];
|
|
832
|
+
parts.push(`median of ${med.runs} past ${med.runs === 1 ? one : many} here: ${med.minutes} min${med.costUsd !== null ? `, $${med.costUsd.toFixed(2)}` : ''}`);
|
|
833
|
+
}
|
|
834
|
+
if (parts.length === 0 && cost !== null)
|
|
835
|
+
parts.push(`${cost[0].toUpperCase()}${cost.slice(1)} cost`);
|
|
836
|
+
return { minutes, cost, steps, median: med, label: parts.join(' · ') };
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Each source token, authorized (DES §4.2 "From"). A run or document must be named by the person in this chat (A1's
|
|
840
|
+
* `named`, or the artifact open on this turn) — or, for an edit, added by the person now (`personNamed`) — and be in
|
|
841
|
+
* the chat's scope and exist. What fails is struck with the reason; the draft never sees it.
|
|
842
|
+
*/
|
|
843
|
+
async resolveSources(tokens, ctx) {
|
|
844
|
+
const out = [];
|
|
845
|
+
const seen = new Set();
|
|
846
|
+
const mine = chatMount(ctx.chat.projectId);
|
|
847
|
+
let runs = null;
|
|
848
|
+
const allRuns = async () => (runs ??= await this.deps.runs());
|
|
849
|
+
const canonical = new Set();
|
|
850
|
+
const push = (src) => {
|
|
851
|
+
// Two spellings of one source (a prefix and the full id) are one chip.
|
|
852
|
+
if (canonical.has(src.token))
|
|
853
|
+
return;
|
|
854
|
+
canonical.add(src.token);
|
|
855
|
+
out.push(src);
|
|
856
|
+
};
|
|
857
|
+
const strike = (token, label, reason, projectId) => {
|
|
858
|
+
push({ token, label, status: 'struck', reason, ...(projectId !== undefined ? { projectId } : {}) });
|
|
859
|
+
};
|
|
860
|
+
for (const raw of tokens.slice(0, PROPOSAL_SOURCES_MAX)) {
|
|
861
|
+
const token = raw.trim();
|
|
862
|
+
if (seen.has(token))
|
|
863
|
+
continue;
|
|
864
|
+
seen.add(token);
|
|
865
|
+
if (token === 'chat') {
|
|
866
|
+
push({ token, label: 'this conversation', status: 'ok' });
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (token === 'page') {
|
|
870
|
+
// A3: studio's page summary rides with its message and is not kept after it, so a document cannot be made from
|
|
871
|
+
// it later: struck with the reason, never promised on the card and then dropped at launch.
|
|
872
|
+
strike(token, 'the page you were on', ctx.turnHas && ctx.binding?.page === true ? "studio's page summary is not kept after the message" : 'no page was shared with this message');
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const colon = token.indexOf(':');
|
|
876
|
+
const kind = colon === -1 ? '' : token.slice(0, colon);
|
|
877
|
+
const id = colon === -1 ? '' : token.slice(colon + 1).trim();
|
|
878
|
+
if (kind === 'message') {
|
|
879
|
+
// The turn's answer when it has one (the token is "one earlier answer"), else the person's own message.
|
|
880
|
+
const answer = ctx.records.find((r) => r.kind === 'seat' && r.turnId === id && r.ok);
|
|
881
|
+
const asked = ctx.records.find((r) => r.kind === 'user' && r.turnId === id);
|
|
882
|
+
const rec = answer ?? asked;
|
|
883
|
+
if (rec === undefined)
|
|
884
|
+
strike(token, `message ${id.slice(0, 8)}`, 'not a message in this chat');
|
|
885
|
+
else {
|
|
886
|
+
const who = rec.kind === 'seat' ? `${rec.cliKey}'s answer` : 'your message';
|
|
887
|
+
push({ token: `message:${id}`, label: `${who} at ${clock(rec.at)}`, status: 'ok', projectId: ctx.chat.projectId });
|
|
888
|
+
}
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (kind === 'attachment') {
|
|
892
|
+
const a = this.deps.attachments(ctx.chatId).find((x) => x.id === id);
|
|
893
|
+
if (a === undefined)
|
|
894
|
+
strike(`attachment:${id}`, `attachment ${id.slice(0, 12)}`, 'not a file attached to this chat');
|
|
895
|
+
// A3: the packet's per-file cap (512 KB): a file over it could never reach the draft.
|
|
896
|
+
else if (typeof a.bytes === 'number' && a.bytes > 512 * 1024)
|
|
897
|
+
strike(`attachment:${a.id}`, a.name, 'too large for a document\'s sources (over 512 KB)');
|
|
898
|
+
else
|
|
899
|
+
push({ token: `attachment:${a.id}`, label: a.name, status: 'ok' });
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (kind === 'run') {
|
|
903
|
+
// A1's run-id grammar; an id matches exactly (custom ids keep their case), and a hex id or prefix also matches
|
|
904
|
+
// case-insensitively (a uuid typed in capitals is the same run).
|
|
905
|
+
const want = id;
|
|
906
|
+
const short = want.slice(0, 8);
|
|
907
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(want)) {
|
|
908
|
+
strike(token, `run ${short}`, 'not a run id');
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
// A1's rules: a custom id only by its exact spelling; a hex id exactly in any case; a hex prefix of 8+ digits.
|
|
912
|
+
const hex = /^[0-9a-fA-F][0-9a-fA-F-]*$/.test(want);
|
|
913
|
+
const low = want.toLowerCase();
|
|
914
|
+
const pick = (ids) => {
|
|
915
|
+
const exact = ids.filter((id) => id === want);
|
|
916
|
+
if (exact.length > 0)
|
|
917
|
+
return exact;
|
|
918
|
+
if (!hex)
|
|
919
|
+
return [];
|
|
920
|
+
const ci = ids.filter((id) => id.toLowerCase() === low);
|
|
921
|
+
if (ci.length > 0)
|
|
922
|
+
return ci;
|
|
923
|
+
// A complete uuid is exact-only (never a prefix of a longer id); a shorter hex id of 8+ digits is a prefix.
|
|
924
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(low))
|
|
925
|
+
return [];
|
|
926
|
+
return low.replace(/-/g, '').length >= 8 ? ids.filter((id) => id.toLowerCase().startsWith(low)) : [];
|
|
927
|
+
};
|
|
928
|
+
// Authorized before anything of it is read: a seat's token is matched against the runs the person named in
|
|
929
|
+
// this chat (full ids), never against every run on the daemon — an unnamed id learns nothing of what exists.
|
|
930
|
+
// A source the person adds is theirs to name, and is looked up.
|
|
931
|
+
let candidates;
|
|
932
|
+
if (ctx.personNamed) {
|
|
933
|
+
candidates = pick((await allRuns()).map((v) => v.session.id));
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
candidates = pick(ctx.binding?.named.runs ?? []);
|
|
937
|
+
if (candidates.length === 0) {
|
|
938
|
+
strike(`run:${want}`, `run ${short}`, 'not named in this chat');
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const hit = candidates.length === 1 ? candidates[0] : null;
|
|
943
|
+
if (candidates.length > 1) {
|
|
944
|
+
strike(`run:${want}`, `run ${short}`, 'matches more than one run');
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
const v = hit === null ? undefined : (await allRuns()).find((x) => x.session.id === hit);
|
|
948
|
+
if (v === undefined) {
|
|
949
|
+
strike(`run:${want}`, `run ${short}`, candidates.length > 1 ? 'matches more than one run' : 'no such run on this daemon');
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
const label = `run ${v.session.id.slice(0, 8)} · ${STATUS_WORDS[String(v.session.status)] ?? 'running'}`;
|
|
953
|
+
const projectId = projectOfMount(runMount(v));
|
|
954
|
+
if (mine !== null && runMount(v) !== mine)
|
|
955
|
+
strike(`run:${v.session.id}`, `run ${v.session.id.slice(0, 8)}`, "not in this chat's project", projectId);
|
|
956
|
+
else
|
|
957
|
+
push({ token: `run:${v.session.id}`, label, status: 'ok', projectId });
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (kind === 'doc') {
|
|
961
|
+
const ref = parseArtifactRef(id);
|
|
962
|
+
if (ref === null) {
|
|
963
|
+
strike(token, id.slice(0, 40), 'not a document reference');
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const projectId = projectOfMount(ref.mount);
|
|
967
|
+
const open = ctx.binding?.artifact?.ref === ref.ref ? ctx.binding.artifact : undefined;
|
|
968
|
+
const named = ctx.personNamed || open !== undefined || (ctx.binding?.named.docs ?? []).includes(ref.ref) || wordsNameRef(ctx.words, ref.ref);
|
|
969
|
+
const title = open?.title ?? titleSeen(ctx.records, ref.ref, ctx.turnId) ?? humanDoc(ref.doc);
|
|
970
|
+
if (!named) {
|
|
971
|
+
strike(`doc:${ref.ref}`, title, 'not open or named in this chat', projectId);
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
if (mine !== null && ref.mount !== mine) {
|
|
975
|
+
strike(`doc:${ref.ref}`, title, "not in this chat's project", projectId);
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const root = await this.deps.docsRoot(ref.projectId);
|
|
979
|
+
const head = root === null ? null : artifactHead(root, ref.doc);
|
|
980
|
+
if (head === null)
|
|
981
|
+
strike(`doc:${ref.ref}`, title, 'no such document', projectId);
|
|
982
|
+
else
|
|
983
|
+
push({ token: `doc:${ref.ref}`, label: `${title} v${head}`, status: 'ok', projectId });
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
strike(token, token.slice(0, 40), 'not a source studio knows');
|
|
987
|
+
}
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
990
|
+
// ── the answer ───────────────────────────────────────────────────────────────────────────────────────────────────
|
|
991
|
+
/**
|
|
992
|
+
* `POST /chats/:id/proposals/:pid` after the route's capability, person and owner checks. `instance` is the chat
|
|
993
|
+
* instance those checks passed against: nothing is read for, or written to, a chat that reused the id since. One
|
|
994
|
+
* lock per proposal: a second tab or a double click waits for the first and gets its result.
|
|
995
|
+
*/
|
|
996
|
+
decide(chatId, pid, body, actor, instance) {
|
|
997
|
+
// Keyed by the pid alone (it is unique: `<chat>:<turn uuid>`), so the same proposal answered through an ended chat's
|
|
998
|
+
// archived alias still waits for an in-flight answer through its first name.
|
|
999
|
+
return this.lock.run(`pid\u0000${pid}`, () => this.decideLocked(chatId, pid, body, actor, instance));
|
|
1000
|
+
}
|
|
1001
|
+
gone(chatId) {
|
|
1002
|
+
return { code: 409, body: { code: 'chat_gone', error: `chat ${chatId} ended and its id was reused; this proposal can no longer be answered here` } };
|
|
1003
|
+
}
|
|
1004
|
+
async decideLocked(chatId, pid, body, actor, instance) {
|
|
1005
|
+
if (this.deps.instanceOf(chatId) !== instance)
|
|
1006
|
+
return this.gone(chatId);
|
|
1007
|
+
const records = this.deps.records(chatId);
|
|
1008
|
+
const folded = foldProposals(records).get(pid);
|
|
1009
|
+
if (folded === undefined)
|
|
1010
|
+
return { code: 404, body: { error: `no proposal ${pid} in chat ${chatId}` } };
|
|
1011
|
+
const kind = folded.proposal.kind;
|
|
1012
|
+
const reread = () => foldProposals(this.deps.records(chatId)).get(pid) ?? folded;
|
|
1013
|
+
const ok = (f) => ({ code: 200, body: { view: viewOf(f) } });
|
|
1014
|
+
// 1. What already happened, before any state check.
|
|
1015
|
+
if (folded.state === 'decided') {
|
|
1016
|
+
if (folded.decision === body.decision)
|
|
1017
|
+
return ok(folded);
|
|
1018
|
+
return { code: 409, body: { code: 'proposal_decided', error: `this proposal was already answered (${folded.decision === 'go' ? 'started' : 'not now'})`, view: viewOf(folded) } };
|
|
1019
|
+
}
|
|
1020
|
+
if (folded.state === 'accepting' && folded.accepting !== undefined) {
|
|
1021
|
+
const acc = folded.accepting;
|
|
1022
|
+
const found = await this.deps.registry.find(kind, acc.identity);
|
|
1023
|
+
if (found !== null) {
|
|
1024
|
+
// Its run exists: adopt it and decide (a crash after the launch, or a lost response).
|
|
1025
|
+
const result = { runId: acc.identity.runId, artifactRef: acc.identity.artifactRef, adopted: true, stub: found.stub };
|
|
1026
|
+
if (!this.decided(chatId, folded.turnId, pid, actor, 'go', result, acc.resolution, acc.edits, instance))
|
|
1027
|
+
return this.gone(chatId);
|
|
1028
|
+
const after = reread();
|
|
1029
|
+
if (body.decision === 'go')
|
|
1030
|
+
return ok(after);
|
|
1031
|
+
return { code: 409, body: { code: 'proposal_decided', error: 'this proposal had already started', view: viewOf(after) } };
|
|
1032
|
+
}
|
|
1033
|
+
if (body.decision === 'not_now') {
|
|
1034
|
+
if (!this.decided(chatId, folded.turnId, pid, actor, 'not_now', null, acc.resolution, acc.edits, instance))
|
|
1035
|
+
return this.gone(chatId);
|
|
1036
|
+
return ok(reread());
|
|
1037
|
+
}
|
|
1038
|
+
// Go again: the frozen choices and identity, re-checked. Only the helpers may change (one may have signed out
|
|
1039
|
+
// since); everything else stays as accepted.
|
|
1040
|
+
let again = acc.resolution;
|
|
1041
|
+
if (body.edits !== undefined) {
|
|
1042
|
+
if (Object.keys(body.edits).some((k) => k !== 'helpers')) {
|
|
1043
|
+
return { code: 400, body: { code: 'edits_invalid', error: 'an interrupted proposal resumes with the choices it was accepted with; only its helpers can change' } };
|
|
1044
|
+
}
|
|
1045
|
+
try {
|
|
1046
|
+
again = await this.applyEdits(chatId, { ...folded, proposal: acc.resolution }, body.edits);
|
|
1047
|
+
}
|
|
1048
|
+
catch (err) {
|
|
1049
|
+
if (err instanceof EditRefusal)
|
|
1050
|
+
return { code: 400, body: { code: 'edits_invalid', error: err.message } };
|
|
1051
|
+
throw err;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return this.go(chatId, folded, again, again === acc.resolution ? acc.edits : { ...(acc.edits ?? {}), ...body.edits }, acc.identity, actor, instance);
|
|
1055
|
+
}
|
|
1056
|
+
if (folded.state === 'superseded') {
|
|
1057
|
+
return { code: 409, body: { code: 'proposal_superseded', error: 'a newer proposal in this chat replaced this one', view: viewOf(folded) } };
|
|
1058
|
+
}
|
|
1059
|
+
// 2. Not now: nothing else is checked.
|
|
1060
|
+
if (body.decision === 'not_now') {
|
|
1061
|
+
if (!this.decided(chatId, folded.turnId, pid, actor, 'not_now', null, folded.proposal, null, instance))
|
|
1062
|
+
return this.gone(chatId);
|
|
1063
|
+
return ok(reread());
|
|
1064
|
+
}
|
|
1065
|
+
// 3. A fresh Go: the edits first (the one block a person can lift is a missing second helper), then the block.
|
|
1066
|
+
let effective;
|
|
1067
|
+
try {
|
|
1068
|
+
effective = await this.applyEdits(chatId, folded, body.edits);
|
|
1069
|
+
}
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
if (err instanceof EditRefusal)
|
|
1072
|
+
return { code: 400, body: { code: 'edits_invalid', error: err.message } };
|
|
1073
|
+
throw err;
|
|
1074
|
+
}
|
|
1075
|
+
if (effective.blocked !== null) {
|
|
1076
|
+
return { code: 409, body: { code: 'proposal_blocked', error: effective.blocked, view: viewOf(folded) } };
|
|
1077
|
+
}
|
|
1078
|
+
return this.go(chatId, folded, effective, body.edits ?? null, this.identityOf(effective), actor, instance);
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* The state checks, then `accepting`, the launch, and `decided` — under the chat's lock and the document's, so two
|
|
1082
|
+
* proposals of one chat (or two chats changing one document) can never both pass the busy check.
|
|
1083
|
+
*/
|
|
1084
|
+
go(chatId, folded, resolution, edits, identity, actor, instance) {
|
|
1085
|
+
const docKey = (resolution.kind === 'refine' || (resolution.kind === 'create' && resolution.artifactRef !== undefined)) && identity.artifactRef !== null ? identity.artifactRef : null;
|
|
1086
|
+
// Keyed by the chat's instance (shared by its original id and an ended chat's archived alias), else its id.
|
|
1087
|
+
return this.lock.run(`chat\u0000${instance ?? chatId}`, () => docKey === null
|
|
1088
|
+
? this.goLocked(chatId, folded, resolution, edits, identity, actor, instance)
|
|
1089
|
+
: this.lock.run(`doc\u0000${docKey}`, () => this.goLocked(chatId, folded, resolution, edits, identity, actor, instance)));
|
|
1090
|
+
}
|
|
1091
|
+
async goLocked(chatId, folded, resolution, edits, identity, actor, instance) {
|
|
1092
|
+
const kind = resolution.kind;
|
|
1093
|
+
const pid = resolution.pid;
|
|
1094
|
+
// Under the chat's lock: a newer proposal published while this one was being checked supersedes it.
|
|
1095
|
+
if (this.deps.instanceOf(chatId) !== instance)
|
|
1096
|
+
return this.gone(chatId);
|
|
1097
|
+
const now = foldProposals(this.deps.records(chatId)).get(pid);
|
|
1098
|
+
if (now === undefined || now.state === 'superseded' || now.state === 'decided' || now.state !== folded.state) {
|
|
1099
|
+
return { code: 409, body: { code: now?.state === 'decided' ? 'proposal_decided' : 'proposal_superseded', error: 'this proposal changed while it was being answered; check the card again', ...(now !== undefined ? { view: viewOf(now) } : {}) } };
|
|
1100
|
+
}
|
|
1101
|
+
const flags = this.deps.flags();
|
|
1102
|
+
if (!flags.proposals || !flags[kind] || !this.deps.registry.has(kind)) {
|
|
1103
|
+
return { code: 409, body: { code: 'operation_unavailable', error: `studio cannot start a ${kind === 'create' ? 'document' : kind} from a chat on this daemon yet`, view: viewOf(folded) } };
|
|
1104
|
+
}
|
|
1105
|
+
// The helpers can still work (one may have signed out since the card was drawn).
|
|
1106
|
+
if (resolution.helpers !== null) {
|
|
1107
|
+
const can = new Set(this.deps.rosterEligible().map((s) => s.key));
|
|
1108
|
+
const lost = resolution.helpers.seats.filter((k) => !can.has(k));
|
|
1109
|
+
if (lost.length > 0) {
|
|
1110
|
+
return { code: 409, body: { code: 'helper_unavailable', error: `${lost.join(' and ')} can't work now; pick another helper`, view: viewOf(folded) } };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Where it is filed still exists (a create or demo files into the chat's project).
|
|
1114
|
+
if ((kind === 'create' || kind === 'demo') && (await this.deps.docsRoot(resolution.filedIn.projectId ?? 'default')) === null) {
|
|
1115
|
+
return { code: 409, body: { code: 'source_gone', error: `${resolution.filedIn.label} is no longer on this daemon`, view: viewOf(folded) } };
|
|
1116
|
+
}
|
|
1117
|
+
// Every source and the target still exist, and are still where they were authorized.
|
|
1118
|
+
const gone = await this.firstGone(chatId, resolution);
|
|
1119
|
+
if (gone !== null)
|
|
1120
|
+
return { code: 409, body: { code: 'source_gone', error: gone, view: viewOf(folded) } };
|
|
1121
|
+
const busy = await this.busy(chatId, identity.artifactRef, resolution);
|
|
1122
|
+
if (busy !== null)
|
|
1123
|
+
return { code: 409, body: { code: busy.code, error: busy.reason, view: viewOf(folded) } };
|
|
1124
|
+
// A refine is on the head it was proposed on; else the card re-resolves (same pid, the new base). Read last, right
|
|
1125
|
+
// before the record: the landing itself is checked against this base (`expect_head`, slices I1 and A5).
|
|
1126
|
+
if (resolution.target !== undefined) {
|
|
1127
|
+
const ref = parseArtifactRef(resolution.target.ref);
|
|
1128
|
+
const root = await this.deps.docsRoot(ref.projectId);
|
|
1129
|
+
const head = root === null ? null : artifactHead(root, ref.doc);
|
|
1130
|
+
if (head === null)
|
|
1131
|
+
return { code: 409, body: { code: 'source_gone', error: `${resolution.target.title} is gone`, view: viewOf(folded) } };
|
|
1132
|
+
if (head !== resolution.target.base) {
|
|
1133
|
+
// A newer proposal exists: this one is not re-opened over it (the chat moved on). The re-resolution is still
|
|
1134
|
+
// recorded, so the fold settles it as superseded for every reader (and its frame says so).
|
|
1135
|
+
const pids = [...foldProposals(this.deps.records(chatId)).keys()];
|
|
1136
|
+
if (pids.indexOf(pid) < pids.length - 1) {
|
|
1137
|
+
const recordedOld = folded.proposal;
|
|
1138
|
+
const freshOld = { ...recordedOld, target: { ...(recordedOld.target ?? resolution.target), base: head } };
|
|
1139
|
+
if (!this.write(chatId, { at: this.now(), turnId: folded.turnId, kind: 'proposal', proposal: freshOld }, instance))
|
|
1140
|
+
return this.gone(chatId);
|
|
1141
|
+
const settled = foldProposals(this.deps.records(chatId)).get(pid);
|
|
1142
|
+
return { code: 409, body: { code: 'proposal_superseded', error: `${resolution.target.title} changed since this was proposed, and a newer proposal replaced it`, ...(settled !== undefined ? { view: viewOf(settled) } : {}) } };
|
|
1143
|
+
}
|
|
1144
|
+
// From the proposal as crew recorded it (never the refused request's edits): the person chooses again.
|
|
1145
|
+
const recorded = folded.proposal;
|
|
1146
|
+
const fresh = { ...recorded, target: { ...(recorded.target ?? resolution.target), base: head } };
|
|
1147
|
+
if (!this.write(chatId, { at: this.now(), turnId: folded.turnId, kind: 'proposal', proposal: fresh }, instance))
|
|
1148
|
+
return this.gone(chatId);
|
|
1149
|
+
const after = foldProposals(this.deps.records(chatId)).get(pid);
|
|
1150
|
+
return {
|
|
1151
|
+
code: 409,
|
|
1152
|
+
body: { code: 'proposal_reresolved', error: `${resolution.target.title} changed since this was proposed (now v${head}); check the card again`, ...(after !== undefined ? { view: viewOf(after) } : {}) },
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
// Recorded before anything starts.
|
|
1157
|
+
const accepting = { at: this.now(), turnId: folded.turnId, kind: 'proposal_accepting', pid, actor: actor.id, edits, resolution, identity };
|
|
1158
|
+
if (!this.write(chatId, accepting, instance))
|
|
1159
|
+
return this.gone(chatId);
|
|
1160
|
+
let result;
|
|
1161
|
+
try {
|
|
1162
|
+
result = await this.deps.registry.launchOnce({ kind, pid, chatId, actor, identity, resolution });
|
|
1163
|
+
}
|
|
1164
|
+
catch (err) {
|
|
1165
|
+
// Accepting stays recorded: the card shows "Interrupted · Go again", which resumes the same identity.
|
|
1166
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1167
|
+
this.deps.audit('chat.proposal.launch_failed', actor, { chatId, pid, error: msg });
|
|
1168
|
+
const after = foldProposals(this.deps.records(chatId)).get(pid);
|
|
1169
|
+
return { code: 502, body: { code: 'launch_failed', error: `could not start it: ${msg}. Go again resumes the same job.`, ...(after !== undefined ? { view: viewOf(after) } : {}) } };
|
|
1170
|
+
}
|
|
1171
|
+
if (!this.decided(chatId, folded.turnId, pid, actor, 'go', result, resolution, edits, instance))
|
|
1172
|
+
return this.gone(chatId);
|
|
1173
|
+
const after = foldProposals(this.deps.records(chatId)).get(pid) ?? folded;
|
|
1174
|
+
return { code: 200, body: { view: viewOf(after) } };
|
|
1175
|
+
}
|
|
1176
|
+
/** Record the answer (and audit exactly what was answered); `false` when the chat is no longer this instance. */
|
|
1177
|
+
decided(chatId, turnId, pid, actor, decision, result, resolution, edits, instance) {
|
|
1178
|
+
if (!this.write(chatId, { at: this.now(), turnId, kind: 'proposal_decided', pid, actor: actor.id, decision, result }, instance))
|
|
1179
|
+
return false;
|
|
1180
|
+
this.deps.audit(decision === 'go' ? 'chat.proposal.accepted' : 'chat.proposal.declined', actor, { chatId, pid, kind: resolution.kind, resolution, edits, result });
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
/** The identity a Go is named by (DES §4.3 step 2). */
|
|
1184
|
+
identityOf(p) {
|
|
1185
|
+
const runId = proposalRunId(p.pid);
|
|
1186
|
+
if (p.kind === 'refine')
|
|
1187
|
+
return { artifactRef: p.target?.ref ?? null, runId };
|
|
1188
|
+
if (p.kind === 'create') {
|
|
1189
|
+
// A retry fills the same document again (§4.3: artifact identity and attempt identity are separate).
|
|
1190
|
+
if (p.artifactRef !== undefined)
|
|
1191
|
+
return { artifactRef: p.artifactRef, runId };
|
|
1192
|
+
const mount = chatMount(p.filedIn.projectId) ?? UNFILED_MOUNT;
|
|
1193
|
+
return { artifactRef: `${mount}/${proposalDocName(p.pid, p.what.title)}`, runId };
|
|
1194
|
+
}
|
|
1195
|
+
return { artifactRef: null, runId };
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* The first source (or the target) that no longer exists, or is no longer where it was authorized, as words; `null`
|
|
1199
|
+
* = all there. The stored authorization (named in this chat) is never re-derived; where it lives is re-checked.
|
|
1200
|
+
*/
|
|
1201
|
+
async firstGone(chatId, p) {
|
|
1202
|
+
let runs = null;
|
|
1203
|
+
// A3: a repository the card names must still be registered (never silently dropped from the launch).
|
|
1204
|
+
if ((p.repoRefs ?? []).length > 0 && this.deps.repoIds !== undefined) {
|
|
1205
|
+
const have = await this.deps.repoIds();
|
|
1206
|
+
const lost = (p.repoRefs ?? []).findIndex((id) => !have.has(id));
|
|
1207
|
+
if (lost !== -1)
|
|
1208
|
+
return `${p.repos[lost] ?? p.repoRefs[lost]} is no longer registered on this daemon; ask again in the chat`;
|
|
1209
|
+
}
|
|
1210
|
+
for (const s of p.sources) {
|
|
1211
|
+
if (s.status !== 'ok')
|
|
1212
|
+
continue;
|
|
1213
|
+
if (s.token.startsWith('run:')) {
|
|
1214
|
+
runs ??= await this.deps.runs();
|
|
1215
|
+
const id = s.token.slice(4);
|
|
1216
|
+
const v = runs.find((x) => x.session.id === id);
|
|
1217
|
+
if (v === undefined)
|
|
1218
|
+
return `${s.label} is no longer on this daemon`;
|
|
1219
|
+
if (s.projectId !== undefined && projectOfMount(runMount(v)) !== s.projectId)
|
|
1220
|
+
return `${s.label} moved to another project since this was proposed`;
|
|
1221
|
+
}
|
|
1222
|
+
else if (s.token.startsWith('doc:')) {
|
|
1223
|
+
const ref = parseArtifactRef(s.token.slice(4));
|
|
1224
|
+
const root = ref === null ? null : await this.deps.docsRoot(ref.projectId);
|
|
1225
|
+
if (ref === null || root === null || !artifactExists(root, ref.doc))
|
|
1226
|
+
return `${s.label} is gone`;
|
|
1227
|
+
}
|
|
1228
|
+
else if (s.token.startsWith('attachment:')) {
|
|
1229
|
+
if (!this.deps.attachments(chatId).some((a) => a.id === s.token.slice(11)))
|
|
1230
|
+
return `${s.label} is no longer attached`;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return null;
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* One live job per chat (work and artifact runs together) and one per document, whoever started it (DES §4.3).
|
|
1237
|
+
* `null` = free.
|
|
1238
|
+
*/
|
|
1239
|
+
async busy(chatId, artifactRef, p) {
|
|
1240
|
+
const runs = await this.deps.runs();
|
|
1241
|
+
for (const f of foldProposals(this.deps.records(chatId)).values()) {
|
|
1242
|
+
if (f.proposal.pid === p.pid)
|
|
1243
|
+
continue;
|
|
1244
|
+
// Another proposal of this chat started a job (decided go, or accepting with its run launched) still live.
|
|
1245
|
+
const runId = f.state === 'decided' && f.decision === 'go' ? f.result?.runId ?? null
|
|
1246
|
+
: f.state === 'accepting' ? f.accepting?.identity.runId ?? null : null;
|
|
1247
|
+
if (runId !== null && (await this.deps.registry.live(f.proposal.kind, runId))) {
|
|
1248
|
+
return { code: 'chat_busy', reason: `This chat already has a job running (${f.proposal.what.label}). Wait, or stop that run.` };
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
for (const v of runs) {
|
|
1252
|
+
if (!isLive(v) || this.deps.chatOfRun(v.session.id) !== chatId || this.deps.isAskPath(v.session.id))
|
|
1253
|
+
continue;
|
|
1254
|
+
return { code: 'chat_busy', reason: `This chat already has a job running (run ${v.session.id.slice(0, 8)}). Wait, or stop that run.` };
|
|
1255
|
+
}
|
|
1256
|
+
if (artifactRef !== null && (p.kind === 'refine' || (p.kind === 'create' && p.artifactRef !== undefined))) {
|
|
1257
|
+
if (await this.deps.registry.liveForArtifact(artifactRef)) {
|
|
1258
|
+
return { code: 'document_busy', reason: `“${p.target?.title ?? p.what.title}” is being ${p.kind === 'create' ? 'drafted' : 'revised'}. Wait, or stop that run.` };
|
|
1259
|
+
}
|
|
1260
|
+
const ref = parseArtifactRef(artifactRef);
|
|
1261
|
+
if (ref !== null) {
|
|
1262
|
+
for (const v of runs) {
|
|
1263
|
+
const doc = v.session.document_id;
|
|
1264
|
+
if (isLive(v) && doc === ref.doc && runMount(v) === ref.mount) {
|
|
1265
|
+
return { code: 'document_busy', reason: `“${p.target?.title ?? p.what.title}” is being ${p.kind === 'create' ? 'drafted' : 'revised'}. Wait, or stop that run.` };
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* The person's edits, re-checked with §4.2's rules (no edit can lower assurance: helpers stay two distinct eligible
|
|
1274
|
+
* seats). This slice takes the fields its own card can change; a project, repositories, a demo address and a
|
|
1275
|
+
* refine's `allow` are refused here until the slice whose form edits them (A3, A5, A8). `chatId` is the chat the
|
|
1276
|
+
* route answered (the history entry: after an id reuse, the archived one), never re-derived from the proposal.
|
|
1277
|
+
*/
|
|
1278
|
+
async applyEdits(chatId, f, edits) {
|
|
1279
|
+
const p = f.proposal;
|
|
1280
|
+
if (edits === undefined)
|
|
1281
|
+
return p;
|
|
1282
|
+
const allowed = {
|
|
1283
|
+
create: p.artifactRef !== undefined ? ['title', 'brief', 'sources', 'helpers'] : ['title', 'brief', 'sources', 'helpers', 'repos', 'project'],
|
|
1284
|
+
refine: ['ask', 'helpers'],
|
|
1285
|
+
theme: ['name', 'patch'],
|
|
1286
|
+
demo: ['audience', 'show', 'helpers'],
|
|
1287
|
+
};
|
|
1288
|
+
for (const k of Object.keys(edits)) {
|
|
1289
|
+
if (!allowed[p.kind].includes(k))
|
|
1290
|
+
throw new EditRefusal(`${k} can't be changed on a ${p.kind} proposal here yet`);
|
|
1291
|
+
}
|
|
1292
|
+
let next = { ...p };
|
|
1293
|
+
if (edits.title !== undefined) {
|
|
1294
|
+
const t = edits.title.trim();
|
|
1295
|
+
if (t === '' || t.length > 200)
|
|
1296
|
+
throw new EditRefusal('the title must be 1 to 200 characters');
|
|
1297
|
+
const kindWord = next.what.label.split(' · ')[0] ?? 'Document';
|
|
1298
|
+
next = { ...next, what: { ...next.what, title: t, label: `${kindWord} · ${t}` } };
|
|
1299
|
+
}
|
|
1300
|
+
if (edits.brief !== undefined) {
|
|
1301
|
+
if (edits.brief.length > 8000)
|
|
1302
|
+
throw new EditRefusal('the brief is over 8000 characters');
|
|
1303
|
+
next = { ...next, brief: edits.brief };
|
|
1304
|
+
}
|
|
1305
|
+
if (edits.ask !== undefined) {
|
|
1306
|
+
const a = edits.ask.trim();
|
|
1307
|
+
if (a === '' || a.length > 4000)
|
|
1308
|
+
throw new EditRefusal('the ask must be 1 to 4000 characters');
|
|
1309
|
+
next = { ...next, brief: a };
|
|
1310
|
+
}
|
|
1311
|
+
if (edits.audience !== undefined || edits.show !== undefined) {
|
|
1312
|
+
if ((edits.audience?.length ?? 0) > 400 || (edits.show?.length ?? 0) > 2000)
|
|
1313
|
+
throw new EditRefusal('the audience or what to show is too long');
|
|
1314
|
+
const show = edits.show ?? p.demo?.show ?? null;
|
|
1315
|
+
// A demo's brief IS what to show: both say the same thing.
|
|
1316
|
+
next = { ...next, brief: show, demo: { url: p.demo?.url ?? '', audience: edits.audience ?? p.demo?.audience ?? null, show } };
|
|
1317
|
+
}
|
|
1318
|
+
if (edits.name !== undefined || edits.patch !== undefined) {
|
|
1319
|
+
const name = (edits.name ?? p.theme?.name ?? '').trim();
|
|
1320
|
+
if (name === '' || name.length > 60)
|
|
1321
|
+
throw new EditRefusal('the theme name must be 1 to 60 characters');
|
|
1322
|
+
const patch = edits.patch ?? p.theme?.patch ?? {};
|
|
1323
|
+
const parsed = ThemePatchSchema.safeParse(patch);
|
|
1324
|
+
if (!parsed.success)
|
|
1325
|
+
throw new EditRefusal('the theme patch is not one studio can apply');
|
|
1326
|
+
const clean = parsed.data;
|
|
1327
|
+
next = { ...next, what: { ...next.what, title: name, label: `Theme · ${name}` }, theme: { name, patch: clean, changes: Object.keys(clean) } };
|
|
1328
|
+
}
|
|
1329
|
+
if (edits.helpers !== undefined) {
|
|
1330
|
+
const chat = this.deps.chat(chatId) ?? { projectId: p.filedIn.projectId, repos: [], lead: null, seats: [], analysis: false };
|
|
1331
|
+
const helpers = this.helpers(chat, edits.helpers);
|
|
1332
|
+
if (helpers === null)
|
|
1333
|
+
throw new EditRefusal('pick two different helpers that can work now');
|
|
1334
|
+
// Two helpers lift the one block a person can lift.
|
|
1335
|
+
next = { ...next, helpers, blocked: next.blocked === NEEDS_HELPER ? null : next.blocked };
|
|
1336
|
+
}
|
|
1337
|
+
// A3 (§4.2 Change something, create): the project — only in a chat filed in no project. Every source, repository
|
|
1338
|
+
// and turn is re-checked against the target below; what is outside it is struck (a `message:` whole).
|
|
1339
|
+
let recheck = false;
|
|
1340
|
+
if (edits.project !== undefined) {
|
|
1341
|
+
const chatProject = this.deps.chat(chatId)?.projectId ?? this.deps.projectOf(chatId);
|
|
1342
|
+
if (chatProject !== null)
|
|
1343
|
+
throw new EditRefusal("this chat is filed in a project: its documents stay there");
|
|
1344
|
+
const target = edits.project === null || edits.project === '' || edits.project === 'default' ? null : edits.project;
|
|
1345
|
+
if (target !== null && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(target) || (await this.deps.docsRoot(target)) === null)) {
|
|
1346
|
+
throw new EditRefusal(`there is no project ${target.slice(0, 80)} on this daemon`);
|
|
1347
|
+
}
|
|
1348
|
+
if (target !== p.filedIn.projectId) {
|
|
1349
|
+
recheck = true;
|
|
1350
|
+
next = { ...next, filedIn: { projectId: target, label: target === null ? 'Not in a project · this chat' : ((await this.deps.projectName(target)) ?? target) } };
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (edits.repos !== undefined) {
|
|
1354
|
+
if (edits.repos.length > 8)
|
|
1355
|
+
throw new EditRefusal('at most 8 repositories');
|
|
1356
|
+
// In the chat's scope: the live chat's, else (an ended chat) the repositories the proposal was resolved with.
|
|
1357
|
+
const scope = this.deps.chat(chatId)?.repos ?? (p.repoRefs ?? []).map((id, i) => ({ id, name: p.repos[i] ?? id }));
|
|
1358
|
+
const repos = [];
|
|
1359
|
+
const repoRefs = [];
|
|
1360
|
+
for (const want of edits.repos) {
|
|
1361
|
+
const hits = scope.filter((r) => r.id === want || r.name === want);
|
|
1362
|
+
if (hits.length !== 1)
|
|
1363
|
+
throw new EditRefusal(`${want.slice(0, 120)} ${hits.length === 0 ? "isn't in this chat's scope" : 'names several repositories (name it by id)'}`);
|
|
1364
|
+
if (!repoRefs.includes(hits[0].id)) {
|
|
1365
|
+
repos.push(hits[0].name);
|
|
1366
|
+
repoRefs.push(hits[0].id);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
next = { ...next, repos, ...(repoRefs.length > 0 ? { repoRefs } : {}) };
|
|
1370
|
+
if (repoRefs.length === 0)
|
|
1371
|
+
delete next.repoRefs;
|
|
1372
|
+
}
|
|
1373
|
+
if (recheck)
|
|
1374
|
+
next = await this.recheckProject(next, edits.repos !== undefined);
|
|
1375
|
+
if (edits.sources !== undefined) {
|
|
1376
|
+
if (edits.sources.length > PROPOSAL_SOURCES_MAX)
|
|
1377
|
+
throw new EditRefusal(`at most ${PROPOSAL_SOURCES_MAX} sources`);
|
|
1378
|
+
const records = this.deps.records(chatId);
|
|
1379
|
+
const { binding, turnHas } = bindingOf(records, f.turnId);
|
|
1380
|
+
// A source the person adds counts as named by them, and is checked against the chat's own scope first; then the
|
|
1381
|
+
// project re-check (when the document moves) strikes what is outside the target.
|
|
1382
|
+
const resolved = await this.resolveSources(edits.sources, {
|
|
1383
|
+
chatId,
|
|
1384
|
+
turnId: f.turnId,
|
|
1385
|
+
records,
|
|
1386
|
+
binding,
|
|
1387
|
+
turnHas,
|
|
1388
|
+
chat: { projectId: p.filedIn.projectId },
|
|
1389
|
+
words: personWords(records, f.turnId),
|
|
1390
|
+
personNamed: true,
|
|
1391
|
+
});
|
|
1392
|
+
next = { ...next, sources: resolved };
|
|
1393
|
+
// Re-checked whenever the document is filed outside the chat's own scope (a moved document, or a retry of one).
|
|
1394
|
+
const chatScope = this.deps.chat(chatId)?.projectId ?? this.deps.projectOf(chatId);
|
|
1395
|
+
if (recheck || next.filedIn.projectId !== chatScope)
|
|
1396
|
+
next = await this.recheckProject(next, edits.repos !== undefined);
|
|
1397
|
+
// A source the person ADDED (not one the proposal already held) that cannot be used is said back, never silently
|
|
1398
|
+
// dropped from a launch; one it already held is struck with the reason, as the project change promises.
|
|
1399
|
+
const had = new Set(p.sources.map((s) => s.token));
|
|
1400
|
+
const added = next.sources.find((src) => src.status === 'struck' && !had.has(src.token));
|
|
1401
|
+
if (added !== undefined)
|
|
1402
|
+
throw new EditRefusal(`${added.label}: ${added.reason ?? 'it cannot be used'}`);
|
|
1403
|
+
// The attachments together must fit the packet (1 MB with room for the rest), or one would be left out at launch.
|
|
1404
|
+
const files = this.deps.attachments(chatId);
|
|
1405
|
+
const attached = next.sources
|
|
1406
|
+
.filter((s) => s.status === 'ok' && s.token.startsWith('attachment:'))
|
|
1407
|
+
.reduce((n, s) => n + (files.find((a) => `attachment:${a.id}` === s.token)?.bytes ?? 0), 0);
|
|
1408
|
+
if (attached > 768 * 1024)
|
|
1409
|
+
throw new EditRefusal(`the attached files together are ${Math.round(attached / 1024)} KB: a document's sources take at most 768 KB of attachments; leave one out`);
|
|
1410
|
+
}
|
|
1411
|
+
return next;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* A3 (§4.2): re-check a create against the project it is now filed in. A run or document source from elsewhere is
|
|
1415
|
+
* struck; a `message:` source is struck whole when any record its answer was given is elsewhere (the conversation
|
|
1416
|
+
* keeps only what is in the project, at packet time); repositories outside the project's members are cut (or, when
|
|
1417
|
+
* the person chose them, refused).
|
|
1418
|
+
*/
|
|
1419
|
+
async recheckProject(p, personRepos) {
|
|
1420
|
+
const target = p.filedIn.projectId;
|
|
1421
|
+
const label = target === null ? 'Unfiled' : p.filedIn.label;
|
|
1422
|
+
const records = this.deps.records(p.chatId);
|
|
1423
|
+
let runs = null;
|
|
1424
|
+
const runProject = async (id) => {
|
|
1425
|
+
runs ??= await this.deps.runs();
|
|
1426
|
+
const v = runs.find((x) => x.session.id === id);
|
|
1427
|
+
return v === undefined ? undefined : projectOfMount(runMount(v));
|
|
1428
|
+
};
|
|
1429
|
+
const sources = [];
|
|
1430
|
+
for (const s of p.sources) {
|
|
1431
|
+
if (s.status !== 'ok') {
|
|
1432
|
+
sources.push(s);
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
if (s.token.startsWith('run:') || s.token.startsWith('doc:')) {
|
|
1436
|
+
const proj = s.projectId !== undefined ? s.projectId : s.token.startsWith('run:') ? await runProject(s.token.slice(4)) : projectOfMount(parseArtifactRef(s.token.slice(4))?.mount ?? '');
|
|
1437
|
+
sources.push(proj === target ? s : { ...s, status: 'struck', reason: `not in ${label}` });
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
if (s.token.startsWith('message:')) {
|
|
1441
|
+
const turn = s.token.slice('message:'.length);
|
|
1442
|
+
const ctx = records.find((r) => r.kind === 'context' && r.turnId === turn);
|
|
1443
|
+
let inside = true;
|
|
1444
|
+
for (const r of ctx?.runs ?? [])
|
|
1445
|
+
if ((await runProject(r.id)) !== target)
|
|
1446
|
+
inside = false;
|
|
1447
|
+
if (ctx?.artifact !== undefined && projectOfMount(parseArtifactRef(ctx.artifact.ref)?.mount ?? '') !== target)
|
|
1448
|
+
inside = false;
|
|
1449
|
+
sources.push(inside ? s : { ...s, status: 'struck', reason: `it was answered from records outside ${label}` });
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
sources.push(s);
|
|
1453
|
+
}
|
|
1454
|
+
let repos = p.repos;
|
|
1455
|
+
let repoRefs = p.repoRefs ?? [];
|
|
1456
|
+
if (target !== null && repoRefs.length > 0) {
|
|
1457
|
+
const members = (await this.deps.projectRepos?.(target)) ?? [];
|
|
1458
|
+
const outside = repoRefs.filter((id) => !members.some((m) => m.id === id));
|
|
1459
|
+
if (outside.length > 0 && personRepos) {
|
|
1460
|
+
throw new EditRefusal(`${outside.map((id) => p.repos[repoRefs.indexOf(id)] ?? id).join(', ')} ${outside.length === 1 ? 'is' : 'are'} not in ${label}`);
|
|
1461
|
+
}
|
|
1462
|
+
repos = repos.filter((_, i) => !outside.includes(repoRefs[i]));
|
|
1463
|
+
repoRefs = repoRefs.filter((id) => !outside.includes(id));
|
|
1464
|
+
}
|
|
1465
|
+
const out = { ...p, sources, repos, ...(repoRefs.length > 0 ? { repoRefs } : {}) };
|
|
1466
|
+
if (repoRefs.length === 0)
|
|
1467
|
+
delete out.repoRefs;
|
|
1468
|
+
return out;
|
|
1469
|
+
}
|
|
1470
|
+
// ── A3: what the create launcher and the daemon need from the proposals ────────────────────────────────────────────
|
|
1471
|
+
/**
|
|
1472
|
+
* The accepted proposal a create's `chat_id` + `proposal_id` names, for this actor (§5.2): accepting (or decided go),
|
|
1473
|
+
* accepted by them, a create. `null` otherwise — and then the create is refused.
|
|
1474
|
+
*/
|
|
1475
|
+
grantFor(chatId, pid, actorId) {
|
|
1476
|
+
const f = foldProposals(this.deps.records(chatId)).get(pid);
|
|
1477
|
+
const acc = f?.accepting;
|
|
1478
|
+
if (f === undefined || acc === undefined || acc.actor !== actorId || acc.resolution.kind !== 'create')
|
|
1479
|
+
return null;
|
|
1480
|
+
if (!(f.state === 'accepting' || (f.state === 'decided' && f.decision === 'go')))
|
|
1481
|
+
return null;
|
|
1482
|
+
const ref = acc.identity.artifactRef === null ? null : parseArtifactRef(acc.identity.artifactRef);
|
|
1483
|
+
if (ref === null)
|
|
1484
|
+
return null;
|
|
1485
|
+
const ids = acc.resolution.repoRefs ?? [];
|
|
1486
|
+
return { doc: ref.doc, projectId: ref.projectId, repos: ids.map((id, i) => ({ id, name: acc.resolution.repos[i] ?? id })) };
|
|
1487
|
+
}
|
|
1488
|
+
/** The accepted resolution and identity of a proposal (the launcher's bus trigger rebuilds its dispatch from it). */
|
|
1489
|
+
acceptedOf(chatId, pid) {
|
|
1490
|
+
const f = foldProposals(this.deps.records(chatId)).get(pid);
|
|
1491
|
+
if (f?.accepting === undefined)
|
|
1492
|
+
return null;
|
|
1493
|
+
return { actor: f.accepting.actor, resolution: f.accepting.resolution, identity: f.accepting.identity, turnId: f.turnId, state: f.state };
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* §6.2: a proposal-bound attempt ended without landing. Crew appends `<chatId>:retry:<runId>` — the same artifact, the
|
|
1497
|
+
* failed attempt's effective resolution (edits, helpers, sources; the conversation cutoff stays the binding's), a new
|
|
1498
|
+
* run id derived from its own pid — to the live chat or its kept history. Once per failed run.
|
|
1499
|
+
*/
|
|
1500
|
+
async appendRetry(chatId, failedPid, runId, artifactRef, reason, bound) {
|
|
1501
|
+
const instance = bound ?? this.deps.instanceOf(chatId);
|
|
1502
|
+
return this.lock.run(`chat\u0000${instance ?? chatId}`, async () => {
|
|
1503
|
+
// Checked under the lock: only on the chat instance the document was made in, never on another chat that reused
|
|
1504
|
+
// the id meanwhile (the caller resolves the instance's current id again and retries).
|
|
1505
|
+
if (bound !== undefined && bound !== null && this.deps.instanceOf(chatId) !== bound) {
|
|
1506
|
+
throw new Error(`chat ${chatId} is no longer the chat run ${runId} was made from`);
|
|
1507
|
+
}
|
|
1508
|
+
const records = this.deps.records(chatId);
|
|
1509
|
+
const folded = foldProposals(records);
|
|
1510
|
+
const failed = folded.get(failedPid);
|
|
1511
|
+
const base = failed?.accepting?.resolution ?? failed?.proposal;
|
|
1512
|
+
if (failed === undefined || base === undefined) {
|
|
1513
|
+
this.log(`[proposals] chat ${chatId}: run ${runId} failed but proposal ${failedPid} is not in its records — no retry card`);
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
// Named by the chat's own id (an ended chat's archive alias still holds the same proposals).
|
|
1517
|
+
const pid = `${base.chatId}:retry:${runId}`;
|
|
1518
|
+
if (folded.has(pid))
|
|
1519
|
+
return pid;
|
|
1520
|
+
const retry = {
|
|
1521
|
+
...base,
|
|
1522
|
+
pid,
|
|
1523
|
+
turnId: null,
|
|
1524
|
+
sourceRunId: runId,
|
|
1525
|
+
artifactRef,
|
|
1526
|
+
reason: `The last attempt ended without a draft: ${reason}. Try again fills the same document.`,
|
|
1527
|
+
blocked: null,
|
|
1528
|
+
};
|
|
1529
|
+
return this.write(chatId, { at: this.now(), turnId: failed.turnId, kind: 'proposal', proposal: retry }, instance) ? pid : null;
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* The artifact job a chat's proposal started that is still live (busy until its outcome is recorded), as the card
|
|
1534
|
+
* names it, or `null`. A work launch filed under the chat (`POST /runs {chatId}`) is refused while one is: one live
|
|
1535
|
+
* job per chat, work and artifact runs together (DES §4.3).
|
|
1536
|
+
*/
|
|
1537
|
+
async liveJobOf(chatId) {
|
|
1538
|
+
for (const f of foldProposals(this.deps.records(chatId)).values()) {
|
|
1539
|
+
const runId = f.state === 'decided' && f.decision === 'go' ? f.result?.runId ?? null : f.state === 'accepting' ? f.accepting?.identity.runId ?? null : null;
|
|
1540
|
+
if (runId !== null && (await this.deps.registry.live(f.proposal.kind, runId)))
|
|
1541
|
+
return f.proposal.what.label;
|
|
1542
|
+
}
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
/** At daemon start (§6.2): an `accepting` proposal whose run exists is decided (adopted). `true` when it recorded. */
|
|
1546
|
+
async settleAccepting(chatId, pid) {
|
|
1547
|
+
const instance = this.deps.instanceOf(chatId);
|
|
1548
|
+
return this.lock.run(`pid\u0000${pid}`, async () => {
|
|
1549
|
+
const f = foldProposals(this.deps.records(chatId)).get(pid);
|
|
1550
|
+
const acc = f?.accepting;
|
|
1551
|
+
if (f === undefined || acc === undefined || f.state !== 'accepting')
|
|
1552
|
+
return false;
|
|
1553
|
+
const found = await this.deps.registry.find(f.proposal.kind, acc.identity);
|
|
1554
|
+
if (found === null)
|
|
1555
|
+
return false;
|
|
1556
|
+
const result = { runId: acc.identity.runId, artifactRef: acc.identity.artifactRef, adopted: true, stub: found.stub };
|
|
1557
|
+
const actor = { id: acc.actor, kind: 'human', trust: 'operator' };
|
|
1558
|
+
return this.decided(chatId, f.turnId, pid, actor, 'go', result, acc.resolution, acc.edits, instance);
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
//# sourceMappingURL=chat-proposals.js.map
|