@sigrun/escalations 0.0.0-stage → 0.1.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/dist/inbox.js ADDED
@@ -0,0 +1,570 @@
1
+ /**
2
+ * The Inbox view (Task E6): the four-tab list (Open / In progress /
3
+ * Dismissed / Resolved) and the per-item detail page, over the visible
4
+ * buckets of the CALLING principal.
5
+ *
6
+ * - List rows carry a `source` label field and the collection groups on
7
+ * it, so the list groups by job / conversation / agent (spec §4.5).
8
+ * The open tab sorts reopened items first, then newest; resolved is
9
+ * newest first. An answered `next-run` item whose job is disabled
10
+ * shows the badge `Waiting: job disabled`.
11
+ * - Detail mode is a page titled with the escalation title: markdown
12
+ * body, answer form (options in the agent's order, NONE marked
13
+ * recommended — the webui preselects recommended options and the
14
+ * proposed action may be destructive), snooze form seeded at 7 days
15
+ * (number fields have no `default` prop), Dismiss/Withdraw/Restore as
16
+ * page actions, conversation links, and the history as a log block.
17
+ * Orphaned items show only the pinned warn callout and Dismiss.
18
+ * - Every action resolves the item through `findVisible` over the
19
+ * caller's buckets, so another user's id behaves exactly like an
20
+ * unknown id (no title leak). Successful actions call `changedInbox`.
21
+ * - A store read failure renders a `bad` callout with the error message
22
+ * instead of the collection (the render never throws on corrupt data).
23
+ */
24
+ import { startImmediateRun } from "./dispatch.js";
25
+ import { changedInbox } from "./keys.js";
26
+ import { TransitionError, answer, dismiss, restore, snooze, withdraw, } from "./lifecycle.js";
27
+ import { bucketsVisibleTo, findVisible, } from "./store.js";
28
+ const TABS = [
29
+ { id: "open", label: "Open" },
30
+ { id: "active", label: "In progress" },
31
+ { id: "dismissed", label: "Dismissed" },
32
+ { id: "resolved", label: "Resolved" },
33
+ ];
34
+ const ACTIVE_STATUSES = ["answered", "claimed", "snoozed"];
35
+ function isObject(payload) {
36
+ return typeof payload === "object" && payload !== null && !Array.isArray(payload);
37
+ }
38
+ function itemIdOf(payload) {
39
+ if (!isObject(payload))
40
+ return undefined;
41
+ const itemId = payload.itemId;
42
+ return typeof itemId === "string" && itemId !== "" ? itemId : undefined;
43
+ }
44
+ function valuesOf(payload) {
45
+ return isObject(payload) && isObject(payload.values) ? payload.values : {};
46
+ }
47
+ function idsOf(payload) {
48
+ if (!isObject(payload) || !Array.isArray(payload.ids))
49
+ return [];
50
+ return payload.ids.filter((id) => typeof id === "string" && id !== "");
51
+ }
52
+ /** Readable operator-facing text for each stable transition code. */
53
+ function transitionMessage(code) {
54
+ switch (code) {
55
+ case "bad_days":
56
+ return "Snooze days must be a whole number between 1 and 365.";
57
+ case "orphaned":
58
+ return "This escalation's job was deleted; only Dismiss is available.";
59
+ case "empty_answer":
60
+ return "Choose an option or write an instruction before sending.";
61
+ case "unknown_option":
62
+ return "That option is not one of the offered choices.";
63
+ case "instruction_not_allowed":
64
+ return "This escalation does not accept a free-text instruction.";
65
+ case "not_open":
66
+ return "Only an open escalation can be answered.";
67
+ case "not_answered":
68
+ return "Only an answered escalation can have its answer withdrawn.";
69
+ case "not_dismissable":
70
+ return "Only an open or answered escalation can be dismissed.";
71
+ case "not_dismissed":
72
+ return "Only a dismissed escalation can be restored.";
73
+ case "not_snoozable":
74
+ return "Only an open or answered escalation can be snoozed.";
75
+ case "not_claimable":
76
+ return "This escalation is not waiting to be claimed.";
77
+ case "not_claimed":
78
+ return "This escalation has no active claim.";
79
+ case "wrong_claimant":
80
+ return "Another conversation holds the claim on this escalation.";
81
+ case "not_reopenable":
82
+ return "This escalation cannot be reopened.";
83
+ default:
84
+ return "That change is not allowed in the escalation's current state.";
85
+ }
86
+ }
87
+ function notFound() {
88
+ return { ok: false, error: { message: "Escalation not found" } };
89
+ }
90
+ function failure(error) {
91
+ if (error instanceof TransitionError) {
92
+ return { ok: false, error: { message: transitionMessage(error.code) } };
93
+ }
94
+ return {
95
+ ok: false,
96
+ error: { message: error instanceof Error ? error.message : String(error) },
97
+ };
98
+ }
99
+ function errorText(error) {
100
+ return error instanceof Error ? error.message : String(error);
101
+ }
102
+ /** The human source label a list row groups under. */
103
+ function sourceLabel(item, jobName) {
104
+ if (item.source.jobId !== undefined) {
105
+ if (item.orphaned === true)
106
+ return "Job deleted";
107
+ return jobName === undefined ? "Job deleted" : `Job ${jobName}`;
108
+ }
109
+ if (item.source.conversationId !== undefined) {
110
+ return `Conversation #${item.source.conversationId}`;
111
+ }
112
+ return "Agent";
113
+ }
114
+ function historyLines(item) {
115
+ return item.history.map((entry) => {
116
+ const parts = [entry.event];
117
+ if (entry.answer?.option !== undefined)
118
+ parts.push(`option=${entry.answer.option}`);
119
+ if (entry.answer?.by !== undefined)
120
+ parts.push(`by=${entry.answer.by}`);
121
+ if (entry.note !== undefined)
122
+ parts.push(entry.note);
123
+ return { at: entry.at, text: parts.join(" — ") };
124
+ });
125
+ }
126
+ export function createInboxView(deps) {
127
+ const { host, store } = deps;
128
+ const now = () => deps.now?.() ?? new Date();
129
+ async function jobOf(item) {
130
+ return item.source.jobId === undefined
131
+ ? undefined
132
+ : await host.jobs.get(item.source.jobId);
133
+ }
134
+ /** List rows for one bucket, with job-derived display fields. */
135
+ async function rowsFor(bucket) {
136
+ const items = await store.list(bucket);
137
+ const rows = [];
138
+ for (const item of items) {
139
+ const job = await jobOf(item);
140
+ rows.push({
141
+ id: item.id,
142
+ title: item.title,
143
+ source: sourceLabel(item, job?.name),
144
+ status: item.status,
145
+ createdAt: item.createdAt,
146
+ updatedAt: item.updatedAt,
147
+ ...(item.resolvedAt === undefined ? {} : { resolvedAt: item.resolvedAt }),
148
+ ...(item.reopenReason === undefined ? {} : { reopenReason: item.reopenReason }),
149
+ ...(item.status === "answered" && item.onAnswer === "next-run" && job?.enabled === false
150
+ ? { waitingBadge: "Waiting: job disabled" }
151
+ : {}),
152
+ });
153
+ }
154
+ return rows;
155
+ }
156
+ function sortForTab(rows, tab) {
157
+ const filtered = rows.filter((row) => tab === "open"
158
+ ? row.status === "open"
159
+ : tab === "active"
160
+ ? ACTIVE_STATUSES.includes(row.status)
161
+ : row.status === tab);
162
+ return filtered.sort((a, b) => {
163
+ if (tab === "open") {
164
+ const aReopened = a.reopenReason === undefined ? 0 : 1;
165
+ const bReopened = b.reopenReason === undefined ? 0 : 1;
166
+ if (aReopened !== bReopened)
167
+ return bReopened - aReopened;
168
+ }
169
+ const aAt = tab === "resolved" ? (a.resolvedAt ?? a.updatedAt) : a.updatedAt;
170
+ const bAt = tab === "resolved" ? (b.resolvedAt ?? b.updatedAt) : b.updatedAt;
171
+ return bAt.localeCompare(aAt);
172
+ });
173
+ }
174
+ /** The card template every tab's collection shares. */
175
+ function cardTemplate() {
176
+ return {
177
+ eyebrow: { $: "./source" },
178
+ title: { $: "./title" },
179
+ status: { label: { $: "./status" } },
180
+ badges: [
181
+ { text: { $: "./reopenReason" }, tone: "warn" },
182
+ // Empty badge text is filtered by the channel, so the waiting
183
+ // badge shows only on rows that carry it.
184
+ { text: { $: "./waitingBadge" }, tone: "warn" },
185
+ ],
186
+ meta: [{ time: { $: "./updatedAt" }, format: "relative" }],
187
+ };
188
+ }
189
+ function collectionFor(tab) {
190
+ const bulk = tab === "open"
191
+ ? [{ id: "dismiss-many", label: "Dismiss selected", onSubmit: "dismiss-many" }]
192
+ : tab === "dismissed"
193
+ ? [{ id: "restore-many", label: "Restore selected", onSubmit: "restore-many" }]
194
+ : [];
195
+ return {
196
+ type: "collection",
197
+ id: `inbox-${tab}`,
198
+ display: "list",
199
+ items: { $: "/items" },
200
+ item: cardTemplate(),
201
+ groupBy: "source",
202
+ ...(bulk.length === 0
203
+ ? {}
204
+ : { select: "multi", bulkActions: bulk }),
205
+ onActivate: { type: "submit", name: "open" },
206
+ };
207
+ }
208
+ function listTree(tab, error) {
209
+ const main = error === undefined
210
+ ? [collectionFor(tab)]
211
+ : [
212
+ {
213
+ type: "callout",
214
+ id: "inbox-error",
215
+ tone: "bad",
216
+ title: "The escalation store could not be read",
217
+ body: error,
218
+ },
219
+ ];
220
+ return {
221
+ v: 1,
222
+ root: {
223
+ type: "page",
224
+ id: "inbox-root",
225
+ title: "Inbox",
226
+ actions: TABS.map((entry) => ({
227
+ id: `tab-${entry.id}`,
228
+ label: entry.label,
229
+ action: { type: "navigate", params: { tab: entry.id } },
230
+ })),
231
+ main,
232
+ },
233
+ };
234
+ }
235
+ /** The not-found page: identical for unknown and for invisible ids. */
236
+ function notFoundTree() {
237
+ return {
238
+ v: 1,
239
+ root: {
240
+ type: "page",
241
+ id: "inbox-not-found",
242
+ title: "Inbox",
243
+ main: [
244
+ {
245
+ type: "callout",
246
+ id: "inbox-not-found-callout",
247
+ tone: "bad",
248
+ title: "Escalation not found",
249
+ },
250
+ ],
251
+ },
252
+ };
253
+ }
254
+ function detailTree(item, jobName) {
255
+ const orphaned = item.orphaned === true;
256
+ const actions = [];
257
+ if (item.status === "open" || item.status === "answered") {
258
+ actions.push({
259
+ id: "dismiss",
260
+ label: "Dismiss",
261
+ action: {
262
+ type: "submit",
263
+ name: "dismiss",
264
+ confirm: {
265
+ title: "Dismiss this escalation?",
266
+ body: "It will not come back unless you restore it. The job will skip this issue.",
267
+ tone: "warn",
268
+ },
269
+ },
270
+ });
271
+ }
272
+ if (item.status === "answered" && !orphaned) {
273
+ actions.push({
274
+ id: "withdraw",
275
+ label: "Withdraw answer",
276
+ action: { type: "submit", name: "withdraw" },
277
+ });
278
+ }
279
+ if (item.status === "dismissed") {
280
+ actions.push({
281
+ id: "restore",
282
+ label: "Restore",
283
+ action: { type: "submit", name: "restore" },
284
+ });
285
+ }
286
+ if (item.source.conversationId !== undefined) {
287
+ actions.push({
288
+ id: "source-conversation",
289
+ label: "Source conversation",
290
+ action: {
291
+ type: "navigate",
292
+ target: { conversation: item.source.conversationId },
293
+ },
294
+ });
295
+ }
296
+ if (item.claim !== undefined) {
297
+ actions.push({
298
+ id: "claim-conversation",
299
+ label: "Claim conversation",
300
+ action: {
301
+ type: "navigate",
302
+ target: { conversation: item.claim.conversationId },
303
+ },
304
+ });
305
+ }
306
+ const main = [];
307
+ if (orphaned) {
308
+ main.push({
309
+ type: "callout",
310
+ id: "inbox-orphaned",
311
+ tone: "warn",
312
+ title: "Job deleted",
313
+ body: "This escalation's job was deleted. Nothing can act on it; dismiss it when you've read it.",
314
+ });
315
+ }
316
+ main.push({ type: "markdown", id: "inbox-body", text: item.body });
317
+ if (item.status === "open" && !orphaned) {
318
+ const fields = [
319
+ {
320
+ type: "choice",
321
+ name: "option",
322
+ label: "Choose an action",
323
+ options: item.options.map((option) => ({
324
+ id: option.id,
325
+ label: option.label,
326
+ ...(option.detail === undefined ? {} : { description: option.detail }),
327
+ })),
328
+ },
329
+ ];
330
+ if (item.allowInstruction) {
331
+ fields.push({
332
+ type: "longtext",
333
+ name: "instruction",
334
+ label: "Instruction (optional)",
335
+ });
336
+ }
337
+ main.push({
338
+ type: "form",
339
+ id: "answer-form",
340
+ bind: "/answer",
341
+ mode: "submit",
342
+ onSubmit: "answer",
343
+ submitLabel: "Send answer",
344
+ children: fields,
345
+ });
346
+ }
347
+ if ((item.status === "open" || item.status === "answered") && !orphaned) {
348
+ main.push({
349
+ type: "form",
350
+ id: "snooze-form",
351
+ bind: "/snooze",
352
+ mode: "submit",
353
+ onSubmit: "snooze",
354
+ submitLabel: "Snooze",
355
+ children: [
356
+ {
357
+ type: "number",
358
+ name: "days",
359
+ label: "Days",
360
+ min: 1,
361
+ max: 365,
362
+ },
363
+ ],
364
+ });
365
+ }
366
+ main.push({
367
+ type: "log",
368
+ id: "inbox-history",
369
+ lines: historyLines(item),
370
+ wrap: true,
371
+ });
372
+ return {
373
+ v: 1,
374
+ root: {
375
+ type: "page",
376
+ id: "inbox-detail",
377
+ title: item.title,
378
+ subtitle: sourceLabel(item, jobName),
379
+ actions,
380
+ main,
381
+ },
382
+ };
383
+ }
384
+ /** The shared action preamble: resolve the item inside the caller's buckets. */
385
+ async function visibleItem(ctx, payload) {
386
+ // Activation/bulk payloads carry {itemId}; detail forms and page
387
+ // submits carry only {values, rev}, so the instance params name it.
388
+ const id = itemIdOf(payload) ??
389
+ (typeof ctx.params.id === "string" ? ctx.params.id : undefined);
390
+ if (id === undefined)
391
+ return undefined;
392
+ const buckets = await bucketsVisibleTo(host, ctx.principal);
393
+ return await findVisible(store, buckets, id);
394
+ }
395
+ /** Apply one lifecycle transition to a visible item and persist it. */
396
+ async function transition(bucket, id, apply) {
397
+ return await store.update(bucket, (items) => {
398
+ const next = items.map((item) => (item.id === id ? apply(item) : item));
399
+ const updated = next.find((item) => item.id === id);
400
+ if (updated === undefined)
401
+ throw new Error("Escalation not found");
402
+ return { items: next, result: updated };
403
+ });
404
+ }
405
+ /** One single-item action: not-found uniformity, transitions, notification. */
406
+ async function actOn(ctx, payload, apply) {
407
+ const found = await visibleItem(ctx, payload);
408
+ if (found === undefined)
409
+ return notFound();
410
+ try {
411
+ const outcome = await apply(found);
412
+ changedInbox(host, found.item.id);
413
+ return {
414
+ ok: true,
415
+ toast: outcome.toast,
416
+ navigate: {
417
+ type: "navigate",
418
+ params: outcome.params ?? { id: found.item.id },
419
+ },
420
+ };
421
+ }
422
+ catch (error) {
423
+ return failure(error);
424
+ }
425
+ }
426
+ /** One bulk action: apply per id, skipping ids that are not actionable. */
427
+ async function actOnMany(ctx, payload, apply, label) {
428
+ const buckets = await bucketsVisibleTo(host, ctx.principal);
429
+ let count = 0;
430
+ for (const id of idsOf(payload)) {
431
+ const found = await findVisible(store, buckets, id);
432
+ if (found === undefined)
433
+ continue;
434
+ try {
435
+ await apply(found);
436
+ changedInbox(host, id);
437
+ count += 1;
438
+ }
439
+ catch {
440
+ // An invalid id or an inapplicable status skips that item.
441
+ }
442
+ }
443
+ return { ok: true, toast: `${label} ${count} escalation(s)` };
444
+ }
445
+ return {
446
+ id: "inbox",
447
+ title: "Inbox",
448
+ icon: "bell",
449
+ placement: "view",
450
+ render: async (ctx) => {
451
+ const buckets = await bucketsVisibleTo(host, ctx.principal);
452
+ if (buckets.length === 0) {
453
+ return {
454
+ tree: {
455
+ v: 1,
456
+ root: {
457
+ type: "page",
458
+ id: "inbox-none",
459
+ title: "Inbox",
460
+ main: [
461
+ {
462
+ type: "markdown",
463
+ id: "inbox-none-text",
464
+ text: "Sign in to see your escalations.",
465
+ },
466
+ ],
467
+ },
468
+ },
469
+ data: {},
470
+ access: "none",
471
+ };
472
+ }
473
+ const id = typeof ctx.params.id === "string" ? ctx.params.id : undefined;
474
+ if (id !== undefined) {
475
+ const found = await findVisible(store, buckets, id);
476
+ if (found === undefined) {
477
+ // Nothing here can be acted on, so the page is not writable.
478
+ return { tree: notFoundTree(), data: {}, access: "none" };
479
+ }
480
+ const job = await jobOf(found.item);
481
+ return {
482
+ tree: detailTree(found.item, job?.name),
483
+ data: { answer: {}, snooze: { days: 7 } },
484
+ access: "write",
485
+ };
486
+ }
487
+ const tabParam = ctx.params.tab;
488
+ const tab = TABS.some((entry) => entry.id === tabParam)
489
+ ? tabParam
490
+ : "open";
491
+ try {
492
+ const rows = (await Promise.all(buckets.map(rowsFor))).flat();
493
+ const data = { items: sortForTab(rows, tab) };
494
+ return {
495
+ tree: listTree(tab),
496
+ // SAFETY: rows are a JSON mirror (plain literals), same cast
497
+ // the curator view uses for its data document.
498
+ data: data,
499
+ access: "write",
500
+ };
501
+ }
502
+ catch (error) {
503
+ return {
504
+ tree: listTree(tab, errorText(error)),
505
+ data: { error: errorText(error) },
506
+ access: "write",
507
+ };
508
+ }
509
+ },
510
+ actions: {
511
+ open: async (_ctx, payload) => {
512
+ const id = itemIdOf(payload);
513
+ if (id === undefined)
514
+ return notFound();
515
+ return {
516
+ ok: true,
517
+ navigate: { type: "navigate", params: { id } },
518
+ };
519
+ },
520
+ answer: async (ctx, payload) => await actOn(ctx, payload, async ({ bucket, item }) => {
521
+ const values = valuesOf(payload);
522
+ const option = typeof values.option === "string" ? values.option : undefined;
523
+ const instruction = typeof values.instruction === "string" && values.instruction !== ""
524
+ ? values.instruction
525
+ : undefined;
526
+ await transition(bucket, item.id, (current) => answer(current, {
527
+ ...(option === undefined ? {} : { option }),
528
+ ...(instruction === undefined ? {} : { instruction }),
529
+ by: ctx.principal.user,
530
+ }, now()));
531
+ if (item.onAnswer === "immediate") {
532
+ // Reconstruct the dispatch seam explicitly: passing the view's
533
+ // deps object would silently satisfy a diverging DispatchDeps
534
+ // and would not prove the injected now() flows through.
535
+ const conversationId = await startImmediateRun({ host: deps.host, store: deps.store, now: deps.now }, bucket, item.id);
536
+ if (conversationId !== undefined) {
537
+ return { toast: "Answer sent — started a run" };
538
+ }
539
+ // The run could not start: the item reopened (dispatch's
540
+ // contract), so never imply that a run is executing.
541
+ return { toast: "Answer sent — no run started; it will reopen" };
542
+ }
543
+ return { toast: "Answer sent" };
544
+ }),
545
+ snooze: async (ctx, payload) => await actOn(ctx, payload, async ({ bucket, item }) => {
546
+ const days = valuesOf(payload).days;
547
+ await transition(bucket, item.id, (current) => snooze(current, typeof days === "number" ? days : Number.NaN, now()));
548
+ return { toast: "Snoozed" };
549
+ }),
550
+ dismiss: async (ctx, payload) => await actOn(ctx, payload, async ({ bucket, item }) => {
551
+ await transition(bucket, item.id, (current) => dismiss(current, now()));
552
+ return { toast: "Dismissed", params: { tab: "open" } };
553
+ }),
554
+ withdraw: async (ctx, payload) => await actOn(ctx, payload, async ({ bucket, item }) => {
555
+ await transition(bucket, item.id, (current) => withdraw(current, now()));
556
+ return { toast: "Answer withdrawn" };
557
+ }),
558
+ restore: async (ctx, payload) => await actOn(ctx, payload, async ({ bucket, item }) => {
559
+ await transition(bucket, item.id, (current) => restore(current, now()));
560
+ return { toast: "Restored" };
561
+ }),
562
+ "dismiss-many": async (ctx, payload) => await actOnMany(ctx, payload, async ({ bucket, item }) => {
563
+ await transition(bucket, item.id, (current) => dismiss(current, now()));
564
+ }, "Dismissed"),
565
+ "restore-many": async (ctx, payload) => await actOnMany(ctx, payload, async ({ bucket, item }) => {
566
+ await transition(bucket, item.id, (current) => restore(current, now()));
567
+ }, "Restored"),
568
+ },
569
+ };
570
+ }
@@ -0,0 +1,6 @@
1
+ import type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
2
+ /**
3
+ * Build the complete escalation surface over one shared durable store.
4
+ * The required host parameter keeps the factory at arity one for the loader.
5
+ */
6
+ export default function createEscalations(host: SigrunHost): SigrunExtensionResult;
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
1
+ import { defineExtension } from "@earendil-works/pi-durable";
2
+ import { createRunSettled } from "./dispatch.js";
3
+ import { createInboxView } from "./inbox.js";
4
+ import { escalationsSection } from "./section.js";
5
+ import { EscalationStore } from "./store.js";
6
+ import { SWEEP_JOB, createSweepTask } from "./sweep.js";
7
+ import { transferItems } from "./transfer.js";
8
+ import { createTools } from "./tools.js";
9
+ /**
10
+ * Build the complete escalation surface over one shared durable store.
11
+ * The required host parameter keeps the factory at arity one for the loader.
12
+ */
13
+ export default function createEscalations(host) {
14
+ const store = new EscalationStore(host);
15
+ const deps = { host, store };
16
+ return {
17
+ extension: defineExtension({
18
+ name: "escalations",
19
+ tools: createTools(deps),
20
+ sections: [escalationsSection],
21
+ tasks: [createSweepTask(deps)],
22
+ }),
23
+ jobs: [SWEEP_JOB],
24
+ runSettled: createRunSettled(deps),
25
+ surfaces: { views: [createInboxView(deps)] },
26
+ userDeleted: async (event) => {
27
+ const source = { kind: "user", user: event.user };
28
+ const target = { kind: "user", user: event.transferTo };
29
+ // Core emits userDeleted before removing the user from the directory
30
+ // (src/server/routes/users.ts:303-305): host.stores.user(u) throws for
31
+ // unknown users, so both bucket paths must be available here.
32
+ const sourceItems = await store.list(source);
33
+ await transferItems(store, source, target, new Set(sourceItems.map((item) => item.id)), new Date());
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,15 @@
1
+ export declare class JsonFile<T> {
2
+ private readonly path;
3
+ private readonly initial;
4
+ private queue;
5
+ constructor(path: string, initial: () => T);
6
+ read(): Promise<T>;
7
+ /**
8
+ * Return the next state from the current one. Returning the same reference
9
+ * is a no-op: the read is serialized, but no replacement is written.
10
+ */
11
+ update(mutate: (current: T) => T | Promise<T>): Promise<T>;
12
+ private enqueue;
13
+ private readNow;
14
+ private writeNow;
15
+ }