@huynle/hub 0.8.6 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +139 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
- package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +18 -1
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-TwvrwqSk.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
package/src/central/spoke-ws.ts
CHANGED
|
@@ -51,7 +51,7 @@ import type { CloseNotice, PodRpc, RpcCall } from "./cluster/rpc"
|
|
|
51
51
|
import type { Auth } from "./auth"
|
|
52
52
|
import type { NodeRegistry } from "./nodes"
|
|
53
53
|
import type { NotificationKind, PushExtra } from "./push"
|
|
54
|
-
import type { Registry } from "./registry"
|
|
54
|
+
import type { FrontAction, Registry } from "./registry"
|
|
55
55
|
import { newID, type Store } from "./store"
|
|
56
56
|
|
|
57
57
|
/** Push notifications (implemented by Push in push.ts) to `ownerID`'s devices. Returns the number of devices notified. */
|
|
@@ -237,6 +237,8 @@ export class SpokeHub {
|
|
|
237
237
|
|
|
238
238
|
constructor(private readonly options: SpokeHubOptions) {
|
|
239
239
|
const staleMs = options.staleMs ?? 60_000
|
|
240
|
+
// (voice front P1) The registry decides about fronts; this sends it (re-link a known front, delete a stale one).
|
|
241
|
+
options.registry.subscribeFrontActions((action) => this.frontAction(action))
|
|
240
242
|
this.started = this.startCluster()
|
|
241
243
|
this.heartbeat = setInterval(() => {
|
|
242
244
|
const now = Date.now()
|
|
@@ -732,10 +734,22 @@ export class SpokeHub {
|
|
|
732
734
|
}
|
|
733
735
|
}
|
|
734
736
|
|
|
737
|
+
/** (voice front P1) Re-links a conversation's front through another process, or deletes a stale front. */
|
|
738
|
+
private frontAction(action: FrontAction): void {
|
|
739
|
+
const { registry } = this.options
|
|
740
|
+
const failed = (cause: unknown) => console.error("[hub] voice front request failed:", cause instanceof Error ? cause.message : cause)
|
|
741
|
+
if (action.kind === "remove") return void this.request(action.connID, { op: "removeFront", sessionID: action.sessionID }).catch(failed)
|
|
742
|
+
this.request(action.connID, { op: "conversation", sessionID: action.mainID, on: true, front: true, frontID: action.frontID }).then((result) => registry.frontAnswer(action.mainID, action.connID, result.front?.sessionID), failed)
|
|
743
|
+
}
|
|
744
|
+
|
|
735
745
|
/** Tells a (re)connected owner about sessions the phone has in conversation mode. */
|
|
736
746
|
private resendConversations(connID: string): void {
|
|
737
|
-
|
|
738
|
-
|
|
747
|
+
const { registry } = this.options
|
|
748
|
+
for (const sessionID of registry.conversationResends(connID)) {
|
|
749
|
+
// (voice front P1) A front asked for is asked for again (only from a spoke that serves it); the answer names it.
|
|
750
|
+
const front = registry.resendFront(sessionID, connID)
|
|
751
|
+
const frontID = front ? registry.resendFrontID(sessionID) : undefined
|
|
752
|
+
this.request(connID, { op: "conversation", sessionID, on: true, ...(front ? { front: true } : {}), ...(frontID ? { frontID } : {}) }).then((result) => registry.frontAnswer(sessionID, connID, front ? result.front?.sessionID : undefined), (cause) => {
|
|
739
753
|
console.error("[hub] re-sending conversation mode failed:", cause instanceof Error ? cause.message : cause)
|
|
740
754
|
})
|
|
741
755
|
}
|
package/src/central/store.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface UserRecord {
|
|
|
37
37
|
speech?: StoredSpeech
|
|
38
38
|
/** The phone's session view: hidden and pinned sessions and workspaces (synced across devices). */
|
|
39
39
|
viewPrefs?: ViewPrefs
|
|
40
|
+
/** (RF2) Hosts and `*.suffix`es whose web images the console loads at once (normalised; absent = none). */
|
|
41
|
+
mediaAllowlist?: string[]
|
|
40
42
|
/** Session ID → per-session Voice and complex-worker model overrides, oldest first. */
|
|
41
43
|
modelPrefs?: Record<string, SessionModelPrefs>
|
|
42
44
|
/** Session ID → when the user last read it (ms), least recently marked first. */
|
|
@@ -903,6 +905,25 @@ export class Store {
|
|
|
903
905
|
})
|
|
904
906
|
}
|
|
905
907
|
|
|
908
|
+
// ---------- per-user media allowlist (RF2) ----------
|
|
909
|
+
|
|
910
|
+
/** A copy of the user's media allowlist (empty when unset or unknown). */
|
|
911
|
+
mediaAllowlist(id: string): string[] {
|
|
912
|
+
const stored = this.user(id)?.mediaAllowlist
|
|
913
|
+
return Array.isArray(stored) ? stored.filter((entry): entry is string => typeof entry === "string") : []
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Replaces the allowlist (callers validate and normalise: parseMediaAllowlist); returns the result. */
|
|
917
|
+
setMediaAllowlist(id: string, allowlist: readonly string[]): Promise<string[]> {
|
|
918
|
+
return this.mutate((tx) => {
|
|
919
|
+
const user = this.user(id)
|
|
920
|
+
if (!user) return []
|
|
921
|
+
user.mediaAllowlist = [...allowlist]
|
|
922
|
+
tx.touch("users", id)
|
|
923
|
+
return this.mediaAllowlist(id)
|
|
924
|
+
})
|
|
925
|
+
}
|
|
926
|
+
|
|
906
927
|
// ---------- per-user view preferences ----------
|
|
907
928
|
|
|
908
929
|
viewPrefs(id: string): ViewPrefs {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Team intake routes on /api/agent (HWA-36; contract: Teams `POST /teams/:id/intake`, scope teams:submit). The rest
|
|
2
2
|
// of the teams surface (definitions, staffing, roster, teams:read|manage) is HWA-37's.
|
|
3
3
|
//
|
|
4
|
-
// POST /teams/:id/intake {title, brief?, priority?, labels?, due?, criteria?, ref?} Idempotency-Key
|
|
4
|
+
// POST /teams/:id/intake {title, brief?, priority?, labels?, due?, notAfter?, criteria?, ref?} Idempotency-Key
|
|
5
|
+
// (notAfter, epoch ms: 400 invalid, 422 expired when already passed and not a replay; queued past it → cancelled)
|
|
5
6
|
// GET /teams/:id/intake?status&limit&cursor → {items, cursor?}
|
|
6
7
|
// GET /teams/:id/intake/:intake → {intake}
|
|
7
8
|
// POST /teams/:id/intake/:intake/status {status, expectedRevision, reason?} → {intake, assignment?}
|
|
@@ -43,6 +43,16 @@
|
|
|
43
43
|
// Delivery is at-least-once: a routing claim (claimMs) keeps two drains from delivering the same item; a crash
|
|
44
44
|
// after delivery and before the routed write re-delivers after the claim expires (the assignment is reused).
|
|
45
45
|
// Events (`team.intake`, family manage) carry IDs, enums and revisions only: never title, brief, labels or criteria.
|
|
46
|
+
//
|
|
47
|
+
// notAfter (H4, hl3hd4iw; the generic "not after T" of ../agent/not-after): an optional deadline (epoch ms) checked
|
|
48
|
+
// at submit by parseNotAfter (invalid, or expired when already passed: nothing is stored; an Idempotency-Key replay
|
|
49
|
+
// of the original body still returns the original). An item still `queued` when it passes is `cancelled` with
|
|
50
|
+
// reason "not_after" (its brief dropped, a leftover assignment cancelled; `team.intake` once, reasonCode not_after),
|
|
51
|
+
// never delivered late. The check runs first in every drain (staffing and presence events, submit, reads) and in
|
|
52
|
+
// sweep(): the team-intake leader runs it at start (so a restart re-arms from stored items), at the earliest stored
|
|
53
|
+
// deadline it knows of and at least every sweepMs (deadlines submitted on another pod). An item claimed by a route in
|
|
54
|
+
// flight is left to that route: Delivery refuses a send past notAfter (expired), which cancels the item at once.
|
|
55
|
+
// Once routed, the team owns it: the message inherits notAfter (Delivery ends a pending hold at the deadline).
|
|
46
56
|
import { createHash, randomBytes } from "node:crypto"
|
|
47
57
|
import {
|
|
48
58
|
ID_PREFIXES,
|
|
@@ -66,8 +76,10 @@ import { sanitizeLabel } from "../agent/envelope"
|
|
|
66
76
|
import { AgentError } from "../agent/errors"
|
|
67
77
|
import type { AgentEvents, EventInput } from "../agent/events"
|
|
68
78
|
import { canonicalBody } from "../agent/idempotency"
|
|
79
|
+
import { parseNotAfter } from "../agent/not-after"
|
|
69
80
|
import { updateVersioned } from "../agent/versioned"
|
|
70
|
-
import
|
|
81
|
+
import { realTimers } from "../backend/memory-bus"
|
|
82
|
+
import type { Clock, StateStore, Timers } from "../backend/types"
|
|
71
83
|
import { assignmentChangedEvent } from "../workspaces/events"
|
|
72
84
|
import type { MembershipStore, SessionPresence, SessionPresenceLookup, StoredAssignment } from "../workspaces/members"
|
|
73
85
|
import type { TeamSeatStore } from "./seats"
|
|
@@ -107,6 +119,8 @@ export interface IntakeLimits {
|
|
|
107
119
|
readonly keysPerTeam: number
|
|
108
120
|
/** How long a routing claim holds before another drain may retry the item. */
|
|
109
121
|
readonly claimMs: number
|
|
122
|
+
/** The longest the deadline sweep waits between passes (deadlines it has not seen yet: submitted on another pod). */
|
|
123
|
+
readonly sweepMs: number
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
export const INTAKE_LIMITS: IntakeLimits = {
|
|
@@ -120,6 +134,7 @@ export const INTAKE_LIMITS: IntakeLimits = {
|
|
|
120
134
|
criterionMax: 200,
|
|
121
135
|
keysPerTeam: 1_000,
|
|
122
136
|
claimMs: 60_000,
|
|
137
|
+
sweepMs: 60_000,
|
|
123
138
|
}
|
|
124
139
|
|
|
125
140
|
/** A TeamIntake as stored and read: the contract record plus additive fields (HWA-36). */
|
|
@@ -128,6 +143,8 @@ export interface StoredIntake extends TeamIntake {
|
|
|
128
143
|
readonly labels?: readonly string[]
|
|
129
144
|
/** Due time (epoch ms). */
|
|
130
145
|
readonly due?: number
|
|
146
|
+
/** Not delivered after this (epoch ms): still queued then → cancelled, reason "not_after". */
|
|
147
|
+
readonly notAfter?: number
|
|
131
148
|
readonly criteria?: readonly string[]
|
|
132
149
|
readonly workspaceID?: string
|
|
133
150
|
/** The entry seat's session it was delivered to. */
|
|
@@ -166,9 +183,15 @@ interface IntakeDoc {
|
|
|
166
183
|
|
|
167
184
|
/**
|
|
168
185
|
* Delivers one task text to a session (production: Delivery.send as intakeActor); `hop`: the chain it continues (a
|
|
169
|
-
* submitting session's depth + 1; production passes it as SendOptions.chainHop)
|
|
186
|
+
* submitting session's depth + 1; production passes it as SendOptions.chainHop); `notAfter`: the item's deadline
|
|
187
|
+
* (SendOptions.notAfter: refused as expired once passed, and a pending hold ends then).
|
|
170
188
|
*/
|
|
171
|
-
export type IntakeDeliver = (
|
|
189
|
+
export type IntakeDeliver = (
|
|
190
|
+
userID: string,
|
|
191
|
+
sessionID: string,
|
|
192
|
+
text: string,
|
|
193
|
+
options?: { readonly hop?: number; readonly notAfter?: number },
|
|
194
|
+
) => Promise<{ readonly messageID: string }>
|
|
172
195
|
|
|
173
196
|
/** The hop a message from the session would carry now (production: Delivery.hops.next). */
|
|
174
197
|
export type IntakeHopOf = (userID: string, sessionID: string) => Promise<number>
|
|
@@ -188,6 +211,9 @@ export interface TeamIntakeDeps {
|
|
|
188
211
|
readonly now?: Clock
|
|
189
212
|
readonly newID?: (kind: IdKind) => string
|
|
190
213
|
readonly limits?: Partial<IntakeLimits>
|
|
214
|
+
/** Every user with intake (the deadline sweep looks at all of them; absent: the sweep does nothing). */
|
|
215
|
+
readonly users?: () => Iterable<string> | Promise<Iterable<string>>
|
|
216
|
+
readonly timers?: Timers
|
|
191
217
|
}
|
|
192
218
|
|
|
193
219
|
export interface IntakeListQuery {
|
|
@@ -211,7 +237,9 @@ export interface IntakeSummary {
|
|
|
211
237
|
readonly oldestQueuedAt?: number
|
|
212
238
|
}
|
|
213
239
|
|
|
214
|
-
const SUBMIT_FIELDS = new Set(["title", "brief", "priority", "labels", "due", "criteria", "ref"])
|
|
240
|
+
const SUBMIT_FIELDS = new Set(["title", "brief", "priority", "labels", "due", "notAfter", "criteria", "ref"])
|
|
241
|
+
/** reasonCode of the event (and `reason` of the item) when an item's notAfter passed while it was queued. */
|
|
242
|
+
export const NOT_AFTER_REASON = "not_after"
|
|
215
243
|
const STATUS_FIELDS = new Set(["status", "expectedRevision", "reason"])
|
|
216
244
|
const CLOSED: ReadonlySet<IntakeStatus> = new Set(["done", "cancelled", "failed"])
|
|
217
245
|
const OPEN_ASSIGNMENT: ReadonlySet<string> = new Set(["open", "in_progress", "blocked"])
|
|
@@ -258,7 +286,13 @@ function view(row: IntakeRow): StoredIntake {
|
|
|
258
286
|
return rest
|
|
259
287
|
}
|
|
260
288
|
|
|
261
|
-
|
|
289
|
+
/**
|
|
290
|
+
* The journal (and webhook) event of an intake change. Data: enums, revision and timestamps; `reasonCode` is the last
|
|
291
|
+
* routing failure's error code, or "not_after" when the item was cancelled because its deadline passed (never the
|
|
292
|
+
* free-text reason). An app's submission names the app as `audience.by`, so that app token's webhooks get its own
|
|
293
|
+
* submissions' changes.
|
|
294
|
+
*/
|
|
295
|
+
const intakeEvent = (row: StoredIntake, reasonCode: string | undefined = row.lastError): EventInput => ({
|
|
262
296
|
type: "team.intake",
|
|
263
297
|
family: "manage",
|
|
264
298
|
subject: {
|
|
@@ -268,7 +302,19 @@ const intakeEvent = (row: StoredIntake): EventInput => ({
|
|
|
268
302
|
...(row.workspaceID !== undefined ? { workspaceID: row.workspaceID } : {}),
|
|
269
303
|
...(row.sessionID !== undefined ? { sessionID: row.sessionID } : {}),
|
|
270
304
|
},
|
|
271
|
-
data: {
|
|
305
|
+
data: {
|
|
306
|
+
status: row.status,
|
|
307
|
+
revision: row.revision,
|
|
308
|
+
source: row.source.kind,
|
|
309
|
+
priority: row.priority,
|
|
310
|
+
...(row.progress !== undefined ? { progress: row.progress } : {}),
|
|
311
|
+
...(reasonCode !== undefined ? { reasonCode } : {}),
|
|
312
|
+
createdAt: row.createdAt,
|
|
313
|
+
updatedAt: row.updatedAt,
|
|
314
|
+
...(row.routedAt !== undefined ? { routedAt: row.routedAt } : {}),
|
|
315
|
+
...(row.closedAt !== undefined ? { closedAt: row.closedAt } : {}),
|
|
316
|
+
},
|
|
317
|
+
...(row.source.kind === "app" ? { audience: { by: `app:${row.source.tokenID}` } } : {}),
|
|
272
318
|
})
|
|
273
319
|
|
|
274
320
|
interface Body {
|
|
@@ -277,6 +323,7 @@ interface Body {
|
|
|
277
323
|
readonly priority: IntakePriority
|
|
278
324
|
readonly labels?: readonly string[]
|
|
279
325
|
readonly due?: number
|
|
326
|
+
readonly notAfter?: number
|
|
280
327
|
readonly criteria?: readonly string[]
|
|
281
328
|
readonly ref?: string
|
|
282
329
|
}
|
|
@@ -296,6 +343,12 @@ export class TeamIntakeService {
|
|
|
296
343
|
private readonly owner = `intake-${process.pid}-${randomBytes(4).toString("hex")}`
|
|
297
344
|
/** Per user and team: the drain in progress (one at a time in this process). */
|
|
298
345
|
private readonly draining = new Map<string, Promise<number>>()
|
|
346
|
+
private readonly timers: Timers
|
|
347
|
+
private sweeping: Promise<number> | undefined
|
|
348
|
+
private started = false
|
|
349
|
+
private timer: unknown
|
|
350
|
+
/** When the armed sweep fires (epoch ms). */
|
|
351
|
+
private armedAt: number | undefined
|
|
299
352
|
|
|
300
353
|
constructor(private readonly deps: TeamIntakeDeps) {
|
|
301
354
|
this.state = deps.state
|
|
@@ -303,6 +356,7 @@ export class TeamIntakeService {
|
|
|
303
356
|
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
304
357
|
this.report = deps.report ?? ((cause) => console.error(`[hub] team intake: ${message(cause)}`))
|
|
305
358
|
this.limits = { ...INTAKE_LIMITS, ...deps.limits }
|
|
359
|
+
this.timers = deps.timers ?? realTimers
|
|
306
360
|
}
|
|
307
361
|
|
|
308
362
|
// ---------- submit ----------
|
|
@@ -310,7 +364,7 @@ export class TeamIntakeService {
|
|
|
310
364
|
/** Submits work to a team; routes it at once when an entry seat is staffed, else it waits queued. */
|
|
311
365
|
async submit(userID: string, actor: AgentActor, teamID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ intake: StoredIntake; created: boolean }> {
|
|
312
366
|
if (actor.kind === "app" && !actor.scopes.includes("teams:submit")) throw new AgentError("forbidden", "Submitting to a team needs scope teams:submit")
|
|
313
|
-
const body = this.body(input)
|
|
367
|
+
const { body, expired } = this.body(input)
|
|
314
368
|
const key = options.idempotencyKey
|
|
315
369
|
if (key !== undefined && !IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
|
|
316
370
|
const team = await this.deps.teams.require(userID, teamID)
|
|
@@ -322,7 +376,7 @@ export class TeamIntakeService {
|
|
|
322
376
|
const slot = key === undefined ? undefined : `${partyOf(actor)}|${key}`
|
|
323
377
|
const id = this.newID("intake")
|
|
324
378
|
const hop = actor.kind === "session" && this.deps.hopOf ? await this.deps.hopOf(userID, actor.sessionID) : undefined
|
|
325
|
-
if (body.brief !== undefined) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
|
|
379
|
+
if (body.brief !== undefined && !expired) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
|
|
326
380
|
let result: { row: IntakeRow; created: boolean } | undefined
|
|
327
381
|
try {
|
|
328
382
|
await this.writeDoc(userID, teamID, (doc) => {
|
|
@@ -337,6 +391,8 @@ export class TeamIntakeService {
|
|
|
337
391
|
return undefined
|
|
338
392
|
}
|
|
339
393
|
}
|
|
394
|
+
// A deadline already passed: refused (nothing stored), unless it is a replay of the original (above).
|
|
395
|
+
if (expired) throw expired
|
|
340
396
|
const recent = doc.items.filter((i) => at - i.createdAt < 60_000)
|
|
341
397
|
if (recent.length >= this.limits.submitsPerMinute) {
|
|
342
398
|
const oldest = Math.min(...recent.map((i) => i.createdAt))
|
|
@@ -355,10 +411,12 @@ export class TeamIntakeService {
|
|
|
355
411
|
throw cause
|
|
356
412
|
}
|
|
357
413
|
if (!result!.created) {
|
|
358
|
-
if (body.brief !== undefined) await this.state.delete(userID, TEAM_INTAKE_BRIEFS, id).catch(this.report)
|
|
359
|
-
|
|
414
|
+
if (body.brief !== undefined && !expired) await this.state.delete(userID, TEAM_INTAKE_BRIEFS, id).catch(this.report)
|
|
415
|
+
await this.serialized(userID, teamID, () => this.expireDue(userID, teamID)).catch(this.report)
|
|
416
|
+
return { intake: view(await this.reconciled(userID, (await this.row(userID, teamID, result!.row.id)) ?? result!.row)), created: false }
|
|
360
417
|
}
|
|
361
418
|
await this.emit(userID, intakeEvent(result!.row))
|
|
419
|
+
if (result!.row.notAfter !== undefined) this.arm(result!.row.notAfter)
|
|
362
420
|
await this.drainSafely(userID, teamID)
|
|
363
421
|
return { intake: view((await this.row(userID, teamID, id)) ?? result!.row), created: true }
|
|
364
422
|
}
|
|
@@ -470,9 +528,14 @@ export class TeamIntakeService {
|
|
|
470
528
|
|
|
471
529
|
/** Routes the team's queued intake, oldest first, while an entry seat is reachable. Returns how many it routed. */
|
|
472
530
|
drain(userID: string, teamID: string): Promise<number> {
|
|
531
|
+
return this.serialized(userID, teamID, () => this.drainNow(userID, teamID))
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Runs `work` after the team's drain or expiry in progress in this process (one at a time per team). */
|
|
535
|
+
private serialized(userID: string, teamID: string, work: () => Promise<number>): Promise<number> {
|
|
473
536
|
const key = `${userID}\u0000${teamID}`
|
|
474
537
|
const previous = this.draining.get(key) ?? Promise.resolve(0)
|
|
475
|
-
const run = previous.catch(() => 0).then(
|
|
538
|
+
const run = previous.catch(() => 0).then(work)
|
|
476
539
|
this.draining.set(key, run)
|
|
477
540
|
void run.finally(() => {
|
|
478
541
|
if (this.draining.get(key) === run) this.draining.delete(key)
|
|
@@ -497,11 +560,113 @@ export class TeamIntakeService {
|
|
|
497
560
|
}
|
|
498
561
|
}
|
|
499
562
|
|
|
563
|
+
// ---------- deadlines (notAfter) ----------
|
|
564
|
+
|
|
565
|
+
/** Runs the deadline sweep now, then at the earliest deadline it finds and at least every sweepMs, until stop(). */
|
|
566
|
+
start(): void {
|
|
567
|
+
if (this.started) return
|
|
568
|
+
this.started = true
|
|
569
|
+
void this.sweep().catch(this.report)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
stop(): void {
|
|
573
|
+
this.started = false
|
|
574
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
575
|
+
this.timer = undefined
|
|
576
|
+
this.armedAt = undefined
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Cancels every user's queued intake whose notAfter has passed (one pass at a time; a call during a pass joins it)
|
|
581
|
+
* and re-arms for the earliest deadline still ahead. Resolves with how many it cancelled.
|
|
582
|
+
*/
|
|
583
|
+
sweep(): Promise<number> {
|
|
584
|
+
this.sweeping ??= this.sweepNow().finally(() => {
|
|
585
|
+
this.sweeping = undefined
|
|
586
|
+
})
|
|
587
|
+
return this.sweeping
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private async sweepNow(): Promise<number> {
|
|
591
|
+
let cancelled = 0
|
|
592
|
+
let next = Number.POSITIVE_INFINITY
|
|
593
|
+
try {
|
|
594
|
+
for (const userID of this.deps.users ? await this.deps.users() : []) {
|
|
595
|
+
for (const { key: teamID, value } of await this.state.list(userID, TEAM_INTAKE)) {
|
|
596
|
+
try {
|
|
597
|
+
const at = this.now()
|
|
598
|
+
const deadlines = readDoc(value, teamID).items.flatMap((i) => (i.status === "queued" && i.notAfter !== undefined ? [i.notAfter] : []))
|
|
599
|
+
if (deadlines.some((d) => d <= at)) cancelled += await this.serialized(userID, teamID, () => this.expireDue(userID, teamID))
|
|
600
|
+
for (const d of deadlines) if (d > at) next = Math.min(next, d)
|
|
601
|
+
} catch (cause) {
|
|
602
|
+
this.report(cause)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
} finally {
|
|
607
|
+
this.arm(next)
|
|
608
|
+
}
|
|
609
|
+
return cancelled
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Arms the sweep for `at` (capped at sweepMs from now) unless it is already armed no later. */
|
|
613
|
+
private arm(at: number): void {
|
|
614
|
+
if (!this.started) return
|
|
615
|
+
const t = this.now()
|
|
616
|
+
const when = Math.min(at, t + this.limits.sweepMs)
|
|
617
|
+
if (this.timer !== undefined && this.armedAt !== undefined && this.armedAt <= when) return
|
|
618
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
619
|
+
this.armedAt = when
|
|
620
|
+
this.timer = this.timers.set(() => {
|
|
621
|
+
this.timer = undefined
|
|
622
|
+
this.armedAt = undefined
|
|
623
|
+
void this.sweep().catch(this.report)
|
|
624
|
+
}, Math.max(0, when - t))
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Cancels the team's queued items whose notAfter has passed (reason not_after): one version-checked write, then per
|
|
629
|
+
* item the brief dropped, a leftover assignment (a failed route) cancelled and one `team.intake` event. Items a live
|
|
630
|
+
* routing claim holds are left to that route; `only` (the route itself, refused by Delivery as expired) expires that
|
|
631
|
+
* one item, claim and all.
|
|
632
|
+
*/
|
|
633
|
+
private async expireDue(userID: string, teamID: string, only?: string): Promise<number> {
|
|
634
|
+
const at = this.now()
|
|
635
|
+
let expired: IntakeRow[] = []
|
|
636
|
+
await this.writeDoc(userID, teamID, (doc) => {
|
|
637
|
+
expired = []
|
|
638
|
+
const items = doc.items.map((row) => {
|
|
639
|
+
if (row.status !== "queued" || row.notAfter === undefined) return row
|
|
640
|
+
// The route refused by Delivery (its clock says the deadline passed) expires its own item, claim and all.
|
|
641
|
+
if (only !== undefined ? row.id !== only : row.notAfter > at || (row.claim !== undefined && at - row.claim.at < this.limits.claimMs)) return row
|
|
642
|
+
const { claim: _c, progress: _p, ...rest } = row
|
|
643
|
+
const next: IntakeRow = { ...rest, status: "cancelled", reason: NOT_AFTER_REASON, closedAt: at, revision: row.revision + 1, updatedAt: at }
|
|
644
|
+
expired.push(next)
|
|
645
|
+
return next
|
|
646
|
+
})
|
|
647
|
+
return expired.length === 0 ? undefined : { ...doc, items: this.retained(items) }
|
|
648
|
+
})
|
|
649
|
+
for (const row of expired) {
|
|
650
|
+
await this.state.delete(userID, TEAM_INTAKE_BRIEFS, row.id).catch(this.report)
|
|
651
|
+
if (row.assignmentID !== undefined && row.workspaceID !== undefined) {
|
|
652
|
+
const assignment = await this.deps.members.assignment(userID, row.workspaceID, row.assignmentID).catch(() => undefined)
|
|
653
|
+
if (assignment && OPEN_ASSIGNMENT.has(assignment.status)) {
|
|
654
|
+
const closed = await this.deps.members.setStatus(userID, row.workspaceID, row.assignmentID, "cancelled").catch((cause) => void this.report(cause))
|
|
655
|
+
if (closed) await this.emit(userID, assignmentChangedEvent(closed, "status"))
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
await this.emit(userID, intakeEvent(row, NOT_AFTER_REASON))
|
|
659
|
+
}
|
|
660
|
+
return expired.length
|
|
661
|
+
}
|
|
662
|
+
|
|
500
663
|
private async drainSafely(userID: string, teamID: string): Promise<void> {
|
|
501
664
|
await this.drain(userID, teamID).catch(this.report)
|
|
502
665
|
}
|
|
503
666
|
|
|
504
667
|
private async drainNow(userID: string, teamID: string): Promise<number> {
|
|
668
|
+
// Past their notAfter: cancelled before anything is routed (also while the team is paused or archived).
|
|
669
|
+
await this.expireDue(userID, teamID)
|
|
505
670
|
const team = await this.deps.teams.get(userID, teamID)
|
|
506
671
|
if (!team || team.state !== "active" || team.paused) return 0
|
|
507
672
|
const queued = readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID)
|
|
@@ -543,6 +708,7 @@ export class TeamIntakeService {
|
|
|
543
708
|
await this.writeDoc(userID, team.id, (doc) => {
|
|
544
709
|
const row = doc.items.find((i) => i.id === intakeID)
|
|
545
710
|
if (!row || row.status !== "queued") return undefined
|
|
711
|
+
if (row.notAfter !== undefined && row.notAfter <= at) return undefined
|
|
546
712
|
if (row.claim && row.claim.owner !== this.owner && at - row.claim.at < this.limits.claimMs) return undefined
|
|
547
713
|
claimed = { ...row, claim: { owner: this.owner, at } }
|
|
548
714
|
return { ...doc, items: doc.items.map((i) => (i.id === intakeID ? claimed! : i)) }
|
|
@@ -554,7 +720,10 @@ export class TeamIntakeService {
|
|
|
554
720
|
await this.mutate(userID, team.id, intakeID, (row) => ({ ...row, assignmentID: assignment.id, workspaceID: target.workspaceID }), false)
|
|
555
721
|
const brief = (await this.state.get<{ brief?: unknown }>(userID, TEAM_INTAKE_BRIEFS, intakeID))?.value?.brief
|
|
556
722
|
const text = this.text(team, claimed, assignment.id, target, typeof brief === "string" ? brief : undefined)
|
|
557
|
-
const sent = await this.deps.deliver(userID, target.sessionID, text,
|
|
723
|
+
const sent = await this.deps.deliver(userID, target.sessionID, text, {
|
|
724
|
+
...(claimed.hop === undefined ? {} : { hop: claimed.hop }),
|
|
725
|
+
...(claimed.notAfter === undefined ? {} : { notAfter: claimed.notAfter }),
|
|
726
|
+
})
|
|
558
727
|
let cancelled = false
|
|
559
728
|
let routed: IntakeRow | undefined
|
|
560
729
|
await this.writeDoc(userID, team.id, (doc) => {
|
|
@@ -581,6 +750,11 @@ export class TeamIntakeService {
|
|
|
581
750
|
return "routed"
|
|
582
751
|
} catch (cause) {
|
|
583
752
|
const code = cause instanceof AgentError ? cause.code : "internal"
|
|
753
|
+
// Delivery refused it: the deadline passed while it was being routed. Cancelled now, never retried.
|
|
754
|
+
if (code === "expired" && claimed.notAfter !== undefined) {
|
|
755
|
+
await this.expireDue(userID, team.id, intakeID).catch(this.report)
|
|
756
|
+
return "skipped"
|
|
757
|
+
}
|
|
584
758
|
this.report(cause)
|
|
585
759
|
await this.mutate(userID, team.id, intakeID, (row) => {
|
|
586
760
|
const { claim: _c, ...rest } = row
|
|
@@ -616,6 +790,7 @@ export class TeamIntakeService {
|
|
|
616
790
|
const from = row.source.kind === "user" ? "the user" : row.source.kind === "app" ? `app ${row.source.tokenID}` : `session ${row.source.sessionID}`
|
|
617
791
|
const facts = [`intake ${row.id}`, `revision ${row.revision + 1}`, `assignment ${assignmentID}`, `workspace ${target.workspaceID}`, `priority ${row.priority}`]
|
|
618
792
|
if (row.due !== undefined) facts.push(`due ${new Date(row.due).toISOString()}`)
|
|
793
|
+
if (row.notAfter !== undefined) facts.push(`not after ${new Date(row.notAfter).toISOString()}`)
|
|
619
794
|
if (row.ref !== undefined) facts.push(`ref ${sanitizeLabel(row.ref, this.limits.refMax)}`)
|
|
620
795
|
const lines = [
|
|
621
796
|
`[hub] team intake for #${team.name} (team ${team.id}, entry role ${team.entryRole}), submitted by ${from}`,
|
|
@@ -665,10 +840,11 @@ export class TeamIntakeService {
|
|
|
665
840
|
|
|
666
841
|
// ---------- internals ----------
|
|
667
842
|
|
|
668
|
-
|
|
843
|
+
/** The checked submission; `expired`: its notAfter has already passed (refused unless it replays the original). */
|
|
844
|
+
private body(input: unknown): { body: Body; expired?: AgentError } {
|
|
669
845
|
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
670
846
|
unknownField(input, SUBMIT_FIELDS)
|
|
671
|
-
const { title, brief, priority, labels, due, criteria, ref } = input
|
|
847
|
+
const { title, brief, priority, labels, due, notAfter, criteria, ref } = input
|
|
672
848
|
const l = this.limits
|
|
673
849
|
if (!isLabel(title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
|
|
674
850
|
if (brief !== undefined && !isText(brief, l.briefMax)) throw new AgentError("invalid", `brief must be at most ${l.briefMax} characters of text`)
|
|
@@ -679,15 +855,24 @@ export class TeamIntakeService {
|
|
|
679
855
|
if (criteria !== undefined && (!Array.isArray(criteria) || criteria.length > l.criteriaPerIntake || !criteria.every((c) => isLabel(c, l.criterionMax))))
|
|
680
856
|
throw new AgentError("invalid", `criteria must be a list of at most ${l.criteriaPerIntake} lines of 1–${l.criterionMax} characters`)
|
|
681
857
|
if (ref !== undefined && !isLabel(ref, l.refMax)) throw new AgentError("invalid", `ref must be 1–${l.refMax} characters of text`)
|
|
682
|
-
|
|
858
|
+
let expired: AgentError | undefined
|
|
859
|
+
try {
|
|
860
|
+
parseNotAfter(notAfter, this.now())
|
|
861
|
+
} catch (cause) {
|
|
862
|
+
if (!(cause instanceof AgentError) || cause.code !== "expired") throw cause
|
|
863
|
+
expired = new AgentError("expired", "notAfter has already passed: nothing was submitted")
|
|
864
|
+
}
|
|
865
|
+
const body: Body = {
|
|
683
866
|
title: title as string,
|
|
684
867
|
...(brief !== undefined ? { brief: brief as string } : {}),
|
|
685
868
|
priority: (priority as IntakePriority | undefined) ?? "normal",
|
|
686
869
|
...(labels !== undefined ? { labels: [...(labels as string[])] } : {}),
|
|
687
870
|
...(due !== undefined ? { due: due as number } : {}),
|
|
871
|
+
...(notAfter !== undefined ? { notAfter: notAfter as number } : {}),
|
|
688
872
|
...(criteria !== undefined ? { criteria: [...(criteria as string[])] } : {}),
|
|
689
873
|
...(ref !== undefined ? { ref: ref as string } : {}),
|
|
690
874
|
}
|
|
875
|
+
return expired ? { body, expired } : { body }
|
|
691
876
|
}
|
|
692
877
|
|
|
693
878
|
/** Reflects an assignment changed through the workspace routes (closed, or another progress) on a routed item. */
|