samagotchi 0.157.0 → 0.159.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -1
- data/README.md +1 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +14 -12
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +2 -1
- data/docs/cli.md +76 -17
- data/docs/configuration.md +16 -5
- data/docs/context.md +12 -4
- data/docs/guardrails.md +11 -8
- data/docs/hooks.md +47 -80
- data/docs/internals/bridge.md +2 -1
- data/docs/internals/bundles.md +51 -36
- data/docs/internals/engine.md +7 -5
- data/docs/memory.md +43 -9
- data/docs/plugins.md +13 -9
- data/docs/releasing.md +6 -5
- data/docs/scheduling.md +228 -0
- data/docs/sessions.md +30 -6
- data/docs/testing.md +4 -4
- data/lib/samagotchi/bridge/card_places.rb +5 -13
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
- data/lib/samagotchi/bridge.rb +26 -2
- data/lib/samagotchi/bundle_command/check.rb +23 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command.rb +53 -44
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/client_id.rb +13 -3
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_sources.rb +4 -2
- data/lib/samagotchi/engine.rb +1 -6
- data/lib/samagotchi/extension_load.rb +20 -41
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/builder.rb +10 -34
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
- data/lib/samagotchi/memory_bundle/installer.rb +32 -14
- data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
- data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
- data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
- data/lib/samagotchi/memory_bundle/status.rb +30 -9
- data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
- data/lib/samagotchi/memory_bundle/trash.rb +7 -12
- data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/plugin/api.rb +1 -4
- data/lib/samagotchi/plugin/loader.rb +42 -5
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/recap_place.rb +3 -4
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +948 -0
- data/lib/samagotchi/session.rb +44 -18
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +20 -0
- data/lib/samagotchi/session_field.rb +5 -4
- data/lib/samagotchi/session_manager/spawn.rb +25 -11
- data/lib/samagotchi/session_manager/worker_command.rb +13 -13
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -5
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/tool_declarations.rb +44 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/delegate.rb +12 -8
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +35 -0
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +10 -2
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +24 -6
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +23 -0
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +10 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
|
@@ -47,6 +47,8 @@ module Samagotchi
|
|
|
47
47
|
"web_fetch" => { content: "url", fallback: :prefix_or_raw },
|
|
48
48
|
"register_reminder" => { content: "name", fallback: :prefix_or_raw },
|
|
49
49
|
"cancel_reminder" => { content: "name", fallback: :prefix_or_raw },
|
|
50
|
+
"schedule_task" => { content: "prompt", fallback: :prefix_or_raw },
|
|
51
|
+
"cancel_schedule" => { content: "name", fallback: :prefix_or_raw },
|
|
50
52
|
"list_sessions" => { fallback: :prefix, fallback_key: "cwd" },
|
|
51
53
|
"send_note" => { content: "text" },
|
|
52
54
|
"context_read" => { content: "name", fallback: :prefix_or_raw },
|
|
@@ -18,6 +18,9 @@ require_relative "web_fetch"
|
|
|
18
18
|
require_relative "register_reminder"
|
|
19
19
|
require_relative "cancel_reminder"
|
|
20
20
|
require_relative "list_reminders"
|
|
21
|
+
require_relative "schedule_task"
|
|
22
|
+
require_relative "list_schedules"
|
|
23
|
+
require_relative "cancel_schedule"
|
|
21
24
|
require_relative "list_sessions"
|
|
22
25
|
require_relative "send_note"
|
|
23
26
|
require_relative "context_read"
|
|
@@ -54,6 +57,9 @@ module Samagotchi
|
|
|
54
57
|
RegisterReminder,
|
|
55
58
|
CancelReminder,
|
|
56
59
|
ListReminders,
|
|
60
|
+
ScheduleTask,
|
|
61
|
+
ListSchedules,
|
|
62
|
+
CancelSchedule,
|
|
57
63
|
ListSessions,
|
|
58
64
|
SendNote,
|
|
59
65
|
ContextRead,
|
|
@@ -103,6 +109,17 @@ module Samagotchi
|
|
|
103
109
|
end,
|
|
104
110
|
CancelReminder::NAME => ->(call, kctx) { CancelReminder.call(call[:content], reminder_store: kctx.reminder_store) },
|
|
105
111
|
ListReminders::NAME => ->(call, kctx) { ListReminders.call(call[:content], reminder_store: kctx.reminder_store) },
|
|
112
|
+
ScheduleTask::NAME => lambda do |call, kctx|
|
|
113
|
+
ScheduleTask.call(call[:content], title: call[:title], prompt: call[:prompt], every: call[:every],
|
|
114
|
+
weekday: call[:weekday], time: call[:time], date: call[:date], mode: call[:mode],
|
|
115
|
+
keep_links: call[:keep_links], peers: kctx.peers)
|
|
116
|
+
end,
|
|
117
|
+
ListSchedules::NAME => lambda do |call, kctx|
|
|
118
|
+
ListSchedules.call(call[:content], include_disabled: call[:include_disabled], all: call[:all], peers: kctx.peers)
|
|
119
|
+
end,
|
|
120
|
+
CancelSchedule::NAME => lambda do |call, kctx|
|
|
121
|
+
CancelSchedule.call(call[:content], all: call[:all], peers: kctx.peers)
|
|
122
|
+
end,
|
|
106
123
|
ListSessions::NAME => ->(call, kctx) { ListSessions.call(call[:content], peers: kctx.peers, cwd: call[:cwd]) },
|
|
107
124
|
SendNote::NAME => ->(call, kctx) { SendNote.call(call[:content], session: call[:session], peers: kctx.peers) },
|
|
108
125
|
ContextRead::NAME => lambda do |call, kctx|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# Cancel a durable schedule of this session's chain by its id, a unique
|
|
6
|
+
# prefix of it, or its title (docs/scheduling.md). A schedule of another
|
|
7
|
+
# session's chain is refused unless the call says `all: true`: `chi
|
|
8
|
+
# schedule cancel` is the machine-wide way.
|
|
9
|
+
class CancelSchedule
|
|
10
|
+
NAME = "cancel_schedule"
|
|
11
|
+
|
|
12
|
+
def self.name = NAME
|
|
13
|
+
|
|
14
|
+
def self.call(content, all: false, peers: nil)
|
|
15
|
+
# Required here, not at the top of the file: the kit does not pay
|
|
16
|
+
# for the session lifecycle until a schedule is actually used.
|
|
17
|
+
require_relative "../schedules"
|
|
18
|
+
name = content.to_s.strip
|
|
19
|
+
return "Error: cancel_schedule needs the schedule's id or title" if name.empty?
|
|
20
|
+
|
|
21
|
+
want_all = all == true || all.to_s.strip.casecmp?("true")
|
|
22
|
+
schedules = Schedules.new(state_dir: peers&.state_dir)
|
|
23
|
+
entry = schedules.lookup(name)
|
|
24
|
+
return "No schedule named #{name}." if entry.nil?
|
|
25
|
+
|
|
26
|
+
if want_all || peers&.session_id.nil?
|
|
27
|
+
schedules.cancel(name)
|
|
28
|
+
return "Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless schedules.chain_of(peers.session_id).any? { |id| id == entry.anchor || id == entry.head }
|
|
32
|
+
return "Error: #{entry.id} belongs to another session's chain. " \
|
|
33
|
+
"chi schedule cancel #{entry.id} cancels it, or say all: true if you meant it."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
schedules.cancel(name)
|
|
37
|
+
"Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
"Error: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -281,9 +281,10 @@ module Samagotchi
|
|
|
281
281
|
private_class_method :unknown_toolkit
|
|
282
282
|
|
|
283
283
|
# The new child's folder: the parent's, or +cwd+ (relative to the
|
|
284
|
-
# parent's) when it is a folder of the parent's
|
|
285
|
-
#
|
|
286
|
-
#
|
|
284
|
+
# parent's) when it is a folder of the parent's project: in any of its
|
|
285
|
+
# repository's worktrees (one project_root, from the common git dir), or
|
|
286
|
+
# in its marked folder. Outside both only a subfolder of the parent's
|
|
287
|
+
# folder. Nothing is created
|
|
287
288
|
# here: the model makes a worktree with execute, where guardrails see it.
|
|
288
289
|
# @return [String] an absolute folder, or an Error: line
|
|
289
290
|
def self.child_cwd(cwd, parent)
|
|
@@ -294,8 +295,8 @@ module Samagotchi
|
|
|
294
295
|
return "Error: cwd #{folder} is not a folder (create the worktree first)" unless File.directory?(folder)
|
|
295
296
|
|
|
296
297
|
folder = File.realpath(folder)
|
|
297
|
-
unless
|
|
298
|
-
return "Error: cwd must be a folder of this session's
|
|
298
|
+
unless same_project?(folder, base)
|
|
299
|
+
return "Error: cwd must be a folder of this session's project (a worktree or subfolder of it), not #{folder}"
|
|
299
300
|
end
|
|
300
301
|
if MemoryPaths.in_repo?(base) && !work_tree?(folder)
|
|
301
302
|
return "Error: cwd #{folder} is not in a work tree of this session's repository (a git dir, or a bare " \
|
|
@@ -327,8 +328,11 @@ module Samagotchi
|
|
|
327
328
|
def self.within?(path, dir) = path == dir || path.start_with?("#{dir}/")
|
|
328
329
|
private_class_method :within?
|
|
329
330
|
|
|
330
|
-
|
|
331
|
-
|
|
331
|
+
# One project (a git repository or a marked folder): the same project
|
|
332
|
+
# root, so a nested repository or marked project is another one. A base
|
|
333
|
+
# in no project: a subfolder of it.
|
|
334
|
+
def self.same_project?(folder, base)
|
|
335
|
+
if MemoryPaths.in_project?(base)
|
|
332
336
|
MemoryPaths.project_root(folder) == MemoryPaths.project_root(base)
|
|
333
337
|
else
|
|
334
338
|
within?(folder, File.realpath(base))
|
|
@@ -336,7 +340,7 @@ module Samagotchi
|
|
|
336
340
|
rescue SystemCallError
|
|
337
341
|
false
|
|
338
342
|
end
|
|
339
|
-
private_class_method :
|
|
343
|
+
private_class_method :same_project?
|
|
340
344
|
|
|
341
345
|
# @return [String] the child's id, or an Error: line
|
|
342
346
|
def self.follow_up(task, session:, parent:, state_dir:)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# The durable schedules of this session's chain (docs/scheduling.md): id,
|
|
6
|
+
# mode, recurrence, next due and target. Another session's schedules are
|
|
7
|
+
# out of reach unless the call says `all: true`; `chi schedule list` is the
|
|
8
|
+
# machine-wide view. Schedules is required inside the call, so the testing
|
|
9
|
+
# kit does not pay for the session lifecycle at load time.
|
|
10
|
+
class ListSchedules
|
|
11
|
+
NAME = "list_schedules"
|
|
12
|
+
|
|
13
|
+
def self.name = NAME
|
|
14
|
+
|
|
15
|
+
def self.call(_content, include_disabled: false, all: false, peers: nil)
|
|
16
|
+
# Required here, not at the top of the file: the kit does not pay
|
|
17
|
+
# for the session lifecycle until a schedule is actually used.
|
|
18
|
+
require_relative "../schedules"
|
|
19
|
+
wanted = include_disabled == true || include_disabled.to_s.strip.casecmp?("true")
|
|
20
|
+
want_all = all == true || all.to_s.strip.casecmp?("true")
|
|
21
|
+
schedules = Samagotchi::Schedules.new(state_dir: peers&.state_dir)
|
|
22
|
+
|
|
23
|
+
scoped = !want_all && peers&.session_id
|
|
24
|
+
entries = scoped ? schedules.for_chain(peers.session_id, include_disabled: wanted) : schedules.entries(include_disabled: wanted)
|
|
25
|
+
|
|
26
|
+
if entries.empty?
|
|
27
|
+
scoped ? "No schedules in this session's chain. `chi schedule list` shows every schedule on this machine." : "No schedules."
|
|
28
|
+
else
|
|
29
|
+
lines = entries.map do |entry|
|
|
30
|
+
flags = []
|
|
31
|
+
flags << "skips #{entry.skips} of #{entry.max_skips}" if entry.skips.positive?
|
|
32
|
+
flags << "disabled after #{entry.failures} failures" unless entry.enabled
|
|
33
|
+
"#{entry.id} #{entry.target_words} #{entry.recurrence_words} next #{entry.next_due} #{flags.join(", ")}".rstrip
|
|
34
|
+
end
|
|
35
|
+
lines << "These are this session's chain only; `chi schedule list` shows every schedule on this machine." if scoped
|
|
36
|
+
lines.join("\n")
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
"Error: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# Register a durable schedule for this session (docs/scheduling.md): a
|
|
6
|
+
# prompt that runs on a clock, out of the worker's memory, until it is
|
|
7
|
+
# cancelled. The default mode is `chain`: each firing is the next link of
|
|
8
|
+
# this session's chain, so a long-running routine keeps a fresh context.
|
|
9
|
+
# Unlike register_reminder, a schedule needs no live worker — a tick run by
|
|
10
|
+
# cron (or chi web's hub) wakes one — and it never holds an idle worker up.
|
|
11
|
+
class ScheduleTask
|
|
12
|
+
NAME = "schedule_task"
|
|
13
|
+
|
|
14
|
+
def self.name = NAME
|
|
15
|
+
|
|
16
|
+
def self.call(content, title: nil, prompt: nil, every: nil, weekday: nil, time: nil, date: nil,
|
|
17
|
+
mode: nil, keep_links: nil, peers: nil)
|
|
18
|
+
# Required here, not at the top of the file: the kit does not pay
|
|
19
|
+
# for the session lifecycle until a schedule is actually used.
|
|
20
|
+
require_relative "../schedules"
|
|
21
|
+
return "Error: this session's id is not known here" unless peers&.session_id
|
|
22
|
+
|
|
23
|
+
prompt = content.to_s if prompt.to_s.strip.empty?
|
|
24
|
+
schedules = Schedules.new(state_dir: peers.state_dir)
|
|
25
|
+
entry = schedules.add(
|
|
26
|
+
target: peers.session_id, prompt: prompt, title: title,
|
|
27
|
+
recurrence: Schedules.recurrence(every: every, weekday: weekday, time: time, date: date),
|
|
28
|
+
mode: mode.nil? || mode.to_s.strip.empty? ? Schedules::CHAIN : mode.to_s.strip,
|
|
29
|
+
keep_links: keep_links
|
|
30
|
+
)
|
|
31
|
+
"Scheduled #{entry.id}: #{entry.target_words} · #{entry.recurrence_words} · next #{entry.next_due}. " \
|
|
32
|
+
"It fires outside this worker (chi schedule tick, run by cron or chi web's hub); " \
|
|
33
|
+
"chi schedule list and cancel_schedule show and stop it."
|
|
34
|
+
rescue Schedules::Usage, Session::NotFound, Session::AmbiguousId => e
|
|
35
|
+
"Error: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -17,6 +17,7 @@ require_relative "../delivery"
|
|
|
17
17
|
require_relative "../answer_tail"
|
|
18
18
|
require_relative "../answer_refs"
|
|
19
19
|
require_relative "../question_asked"
|
|
20
|
+
require_relative "../project_init"
|
|
20
21
|
require_relative "../bridge_client"
|
|
21
22
|
require_relative "../worker_link"
|
|
22
23
|
require_relative "../bridge/bounded_queue"
|
|
@@ -268,6 +269,7 @@ module Samagotchi
|
|
|
268
269
|
["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
|
|
269
270
|
["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
|
|
270
271
|
["GET", %r{\A/api/projects\z}, :handle_projects, :owner],
|
|
272
|
+
["POST", %r{\A/api/projects\z}, :handle_project_create, :owner],
|
|
271
273
|
["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
|
|
272
274
|
["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
|
|
273
275
|
["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
|
|
@@ -1006,6 +1008,39 @@ module Samagotchi
|
|
|
1006
1008
|
json_response(200, ProjectList.build(@hub.snapshot, start_dir: start_dir, current_root: current_root))
|
|
1007
1009
|
end
|
|
1008
1010
|
|
|
1011
|
+
def handle_project_create(req)
|
|
1012
|
+
body = parse_json(request_body(req))
|
|
1013
|
+
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
1014
|
+
|
|
1015
|
+
requested = body["dir"]
|
|
1016
|
+
requested = requested.strip if requested.is_a?(String)
|
|
1017
|
+
unless requested.is_a?(String) && !requested.empty? && !requested.include?("\0")
|
|
1018
|
+
return error_response(400, "invalid_dir", "dir must be an absolute folder")
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
home = Dir.home
|
|
1022
|
+
dir = if requested == "~"
|
|
1023
|
+
home
|
|
1024
|
+
elsif requested.start_with?("~/")
|
|
1025
|
+
File.join(home, requested.delete_prefix("~/"))
|
|
1026
|
+
else
|
|
1027
|
+
requested
|
|
1028
|
+
end
|
|
1029
|
+
return error_response(400, "invalid_dir", "dir must be an absolute folder") unless dir.start_with?("/")
|
|
1030
|
+
|
|
1031
|
+
# One normal form for the decision, the mkdir and the realpath (a `..` after a symlink would differ).
|
|
1032
|
+
dir = File.expand_path(dir)
|
|
1033
|
+
outcome = ProjectInit.call(dir, home: home)
|
|
1034
|
+
return error_response(422, outcome.reason.to_s, outcome.message) if outcome.kind == :refused
|
|
1035
|
+
|
|
1036
|
+
if outcome.kind == :git
|
|
1037
|
+
failed = ProjectInit.ensure_folder(dir)
|
|
1038
|
+
return error_response(422, failed.reason.to_s, failed.message) if failed
|
|
1039
|
+
end
|
|
1040
|
+
dir = File.realpath(dir)
|
|
1041
|
+
json_response(200, outcome: outcome.kind.to_s, root: outcome.root, dir: dir)
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1009
1044
|
# A scope folder from the page: [dir, nil] (dir nil when none was
|
|
1010
1045
|
# given), or [nil, a 400 response] when it isn't an existing absolute
|
|
1011
1046
|
# folder (a bookmarked worktree deleted since, a typo).
|
|
@@ -6,8 +6,8 @@ require_relative "push/attention"
|
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
8
|
module Web
|
|
9
|
-
# Projects represented by the session hub, plus the start
|
|
10
|
-
# when
|
|
9
|
+
# Projects represented by the session hub, plus the start and current
|
|
10
|
+
# projects even when they have no sessions.
|
|
11
11
|
module ProjectList
|
|
12
12
|
module_function
|
|
13
13
|
|
|
@@ -24,6 +24,7 @@ module Samagotchi
|
|
|
24
24
|
(groups[root] ||= []) << summary
|
|
25
25
|
end
|
|
26
26
|
groups[default_root] ||= [] if valid_root?(default_root)
|
|
27
|
+
groups[current_root] ||= [] if valid_root?(current_root)
|
|
27
28
|
|
|
28
29
|
rows = groups.map do |root, sessions|
|
|
29
30
|
default = root == default_root
|
|
@@ -128,10 +128,21 @@ export function chainLinks(list, id, nexts = nextsOf(list), byId = new Map(list.
|
|
|
128
128
|
return links;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Whether the chain's oldest link the page holds still names a link it does
|
|
132
|
+
// not: a schedule's prune (docs/scheduling.md, keep_links) or a delete. The
|
|
133
|
+
// walk stops there, so every count comes from the surviving root, and the page
|
|
134
|
+
// says "earlier links pruned" rather than linking to a session that is gone.
|
|
135
|
+
export function linksPruned(links, byId) {
|
|
136
|
+
const root = links[0];
|
|
137
|
+
return !!root?.continues && !byId.has(root.continues);
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
// The session page's neighbours of +id+: {prev, next}, each {id, session}
|
|
132
|
-
// or null
|
|
133
|
-
//
|
|
134
|
-
//
|
|
141
|
+
// or null, and +pruned+: the session's own `continues` names a link the list
|
|
142
|
+
// does not hold (a schedule pruned it, or it was deleted). +continues+: the
|
|
143
|
+
// open session's own `continues`, when the list may not have caught up with it.
|
|
144
|
+
// A previous link the list doesn't hold is null: its link would open a
|
|
145
|
+
// not-found page.
|
|
135
146
|
export function chainNeighbours(list, id, { continues = null } = {}) {
|
|
136
147
|
const self = list.find((s) => s.id === id);
|
|
137
148
|
const prevId = self?.continues || continues || null;
|
|
@@ -139,7 +150,7 @@ export function chainNeighbours(list, id, { continues = null } = {}) {
|
|
|
139
150
|
const prev = prevSession ? { id: prevId, session: prevSession } : null;
|
|
140
151
|
const nextSession = nextsOf(list).get(id) || null;
|
|
141
152
|
const next = nextSession ? { id: nextSession.id, session: nextSession } : null;
|
|
142
|
-
return { prev, next };
|
|
153
|
+
return { prev, next, pruned: !!prevId && !prevSession };
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
const pad = (n) => String(n).padStart(2, "0");
|
|
@@ -188,14 +199,22 @@ export function foldChains(fams, all, { fold = true } = {}) {
|
|
|
188
199
|
let changed = false;
|
|
189
200
|
for (const f of fams) {
|
|
190
201
|
const links = chainLinks(all, f.head.id, nexts, byId);
|
|
202
|
+
const pruned = linksPruned(links, byId);
|
|
191
203
|
if (links.length < 2) {
|
|
192
|
-
|
|
204
|
+
// A chain pruned to one link is still a chain: its card carries the chip
|
|
205
|
+
// and the popover says what is behind it.
|
|
206
|
+
if (pruned) {
|
|
207
|
+
changed = true;
|
|
208
|
+
out.push({ ...f, chain: { links, at: 0, folded: fold, pruned } });
|
|
209
|
+
} else {
|
|
210
|
+
out.push(f);
|
|
211
|
+
}
|
|
193
212
|
continue;
|
|
194
213
|
}
|
|
195
214
|
changed = true;
|
|
196
215
|
const at = links.findIndex((s) => s.id === f.head.id);
|
|
197
216
|
if (fold && links.slice(at + 1).some((s) => heads.has(s.id))) continue;
|
|
198
|
-
out.push({ ...f, chain: { links, at, folded: fold } });
|
|
217
|
+
out.push({ ...f, chain: { links, at, folded: fold, pruned } });
|
|
199
218
|
}
|
|
200
219
|
return changed ? out : fams;
|
|
201
220
|
}
|
|
@@ -233,7 +252,8 @@ export function chainChipTemplate(f, { open = false } = {}) {
|
|
|
233
252
|
const text = n === links.length ? `#${n}` : `#${n} of ${links.length}`;
|
|
234
253
|
const earlier = at;
|
|
235
254
|
const later = links.length - 1 - at;
|
|
236
|
-
const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""
|
|
255
|
+
const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : "",
|
|
256
|
+
f.chain.pruned ? "earlier links pruned" : ""].filter(Boolean);
|
|
237
257
|
const title = `Session chain: link ${n} of ${links.length} (${parts.join(", ")}); open the list`;
|
|
238
258
|
const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
|
|
239
259
|
const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
|
|
@@ -275,7 +295,11 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
|
|
|
275
295
|
date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${rowTitle(s, headTitle, byId)}</span>${
|
|
276
296
|
rowBadgesTemplate(waiting, stopped, s.status)}${s.archived ? archivedBadge : nothing}</button></div>`;
|
|
277
297
|
});
|
|
278
|
-
|
|
298
|
+
const prunedRow = f?.chain?.pruned
|
|
299
|
+
? html`<div class="chain-row chain-pruned" role="listitem"><span class="cr-n" aria-hidden="true">↩</span>
|
|
300
|
+
<span class="fr-preview">earlier links pruned</span></div>`
|
|
301
|
+
: nothing;
|
|
302
|
+
return html`<div class="family-rows chain-rows" role="list">${rows}${prunedRow}</div>`;
|
|
279
303
|
}
|
|
280
304
|
|
|
281
305
|
// What the page does with a refused continue (POST /api/sessions
|
|
@@ -1384,7 +1384,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
1384
1384
|
/* One run: the groups' chips are the meta's. */
|
|
1385
1385
|
#infoBar .meta .bar-group{display:contents}
|
|
1386
1386
|
#infoBar .meta .bar-sep{display:none}
|
|
1387
|
-
#infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
|
|
1387
|
+
#infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.chain-pruned,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
|
|
1388
1388
|
#infoBar .info-actions{display:none}
|
|
1389
1389
|
#infoBar .meta .status+.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
1390
1390
|
/* ⚙ alone, the start page's dot when the session set its own (its values in the tooltip and accessible name). */
|
|
@@ -1488,6 +1488,11 @@ body.has-session #newBtn .brand-new{display:inline}
|
|
|
1488
1488
|
.project-all::before{content:"";position:absolute;left:-1px;right:-1px;top:-9px;height:1px;background:var(--border);pointer-events:none}
|
|
1489
1489
|
.project-note{padding:5px 2px;color:var(--text-muted);font-size:12px}
|
|
1490
1490
|
.project-error{color:var(--warn-text)}
|
|
1491
|
+
.project-picker .project-new{width:100%;text-align:left;background:var(--bg-inset);color:var(--accent)}
|
|
1492
|
+
.project-create{display:grid;gap:8px;padding:8px 2px}
|
|
1493
|
+
.project-create label{color:var(--text-muted);font-size:12px}
|
|
1494
|
+
.project-create input{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset);color:var(--text)}
|
|
1495
|
+
.project-create-actions{display:flex;justify-content:flex-end;gap:8px}
|
|
1491
1496
|
body.has-session #scope .scope-where{display:none}
|
|
1492
1497
|
/* A phone has no room for both; the hidden strip's pill (count, live) wins. */
|
|
1493
1498
|
@media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
|
|
@@ -1572,6 +1577,9 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
|
|
|
1572
1577
|
.chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
1573
1578
|
/* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
|
|
1574
1579
|
.chain-rows:has(.cr-date.timed) .cr-date{min-width:16ch}
|
|
1580
|
+
/* A chain pruned by a schedule (docs/scheduling.md): the popover's last line and the info bar say the earlier links are gone. */
|
|
1581
|
+
.chain-row.chain-pruned,.chain-row.chain-pruned .cr-n{color:var(--text-muted)}
|
|
1582
|
+
#infoBar .meta .chain-pruned{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap}
|
|
1575
1583
|
/* The session page's links to the previous and the next link of its chain. */
|
|
1576
1584
|
#infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
|
|
1577
1585
|
@media(hover:hover){#infoBar .meta a.chain-link:hover{background:var(--accent-bg)}}
|
|
@@ -1780,7 +1788,7 @@ a{color:var(--accent)}
|
|
|
1780
1788
|
@media (max-width:600px),(pointer:coarse){
|
|
1781
1789
|
#composer textarea,.question-freeform,.continue-reason,.participants-popover input,.participants-popover select,
|
|
1782
1790
|
.rs-form textarea,.pp-link input,.llmctx-field select,.llmctx-field input,.ws-add-in,.rename-input,
|
|
1783
|
-
#modelSearch{font-size:16px}
|
|
1791
|
+
#modelSearch,.project-create input{font-size:16px}
|
|
1784
1792
|
}
|
|
1785
1793
|
</style>
|
|
1786
1794
|
</head>
|
|
@@ -98,10 +98,11 @@ export function infoBarHtml(bar) {
|
|
|
98
98
|
if (!bar) return nothing;
|
|
99
99
|
const memories = bar.memories || [];
|
|
100
100
|
const setup = html`<button type="button" class="id" title=${`Session id ${bar.id}: click to copy it`}>${String(bar.id).slice(0, 8)}</button><span class=${bar.status ? `status ${bar.status}` : "status"}><span>${bar.status || ""}</span></span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${settingsChip(bar.settings)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${bar.workingSet ? workingSetChipTemplate(bar.workingSet.chip) : memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}`;
|
|
101
|
-
const related = !!(bar.chain?.prev || bar.chain?.next || bar.parent || bar.kids || bar.participants);
|
|
102
|
-
//
|
|
101
|
+
const related = !!(bar.chain?.prev || bar.chain?.next || bar.chain?.pruned || bar.parent || bar.kids || bar.participants);
|
|
102
|
+
// A chain pruned by a schedule (docs/scheduling.md): the link before this one
|
|
103
|
+
// is gone, so there is nothing to link to — the bar says so instead.
|
|
103
104
|
const relations = related
|
|
104
|
-
? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
|
|
105
|
+
? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.pruned && !bar.chain?.prev ? html`<span class="model chain-pruned" title="The links before this one were pruned by a schedule">↩ earlier links pruned</span>` : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
|
|
105
106
|
: nothing;
|
|
106
107
|
const telemetry = html`<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class=${`model speed${bar.speed.empty ? " empty" : ""}`} title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}<span class=${`model session-time${bar.clock ? "" : " empty"}`} title=${bar.clock ? "This session's time: its turns, and the running one" : "This session's time: no turn yet"}>${bar.clock || CLOCK_NONE}</span>${workerBadgeTemplate(bar.worker)}`;
|
|
107
108
|
return html`<div class="meta"><span class="bar-group setup">${setup}</span>${relations}<span class="bar-sep" aria-hidden="true"></span><span class="bar-group telemetry">${telemetry}</span></div>`;
|
|
@@ -24,10 +24,19 @@ function projectRow(row) {
|
|
|
24
24
|
</a>`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function projectParent(rootLabel) {
|
|
28
|
+
if (typeof rootLabel !== "string" || !rootLabel) return "~/";
|
|
29
|
+
const slash = rootLabel.lastIndexOf("/");
|
|
30
|
+
if (slash < 0) return "~/";
|
|
31
|
+
const parent = rootLabel.slice(0, slash);
|
|
32
|
+
return parent ? `${parent.replace(/\/$/, "")}/` : "/";
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
// +rows+ is the API's ordered list. When there is no project scope, the
|
|
28
36
|
// all-projects row is current; a bad ?dir= has no current project to mark.
|
|
29
37
|
export function pickerTemplate({ rows = [], loading = false, error = "", currentDir = null,
|
|
30
|
-
allCurrent = false
|
|
38
|
+
allCurrent = false, createOpen = false, createPath = "~/", creating = false,
|
|
39
|
+
createError = "", onPathInput = () => {} } = {}) {
|
|
31
40
|
const allHref = allScopeHref("", currentDir);
|
|
32
41
|
return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
|
|
33
42
|
data-act="close" aria-label="Close">×</button></div>
|
|
@@ -39,7 +48,15 @@ export function pickerTemplate({ rows = [], loading = false, error = "", current
|
|
|
39
48
|
<span class="project-main"><span class="project-name">All projects</span
|
|
40
49
|
><span class="project-hint">Every session</span></span>
|
|
41
50
|
${allCurrent ? html`<span class="project-check" aria-hidden="true">✓</span>` : nothing}
|
|
42
|
-
</a>
|
|
51
|
+
</a>${createOpen ? html`<form class="project-create" aria-busy=${creating}>
|
|
52
|
+
<label for="projectCreatePath">Folder path</label>
|
|
53
|
+
<input id="projectCreatePath" name="dir" type="text" autocomplete="off" .value=${createPath}
|
|
54
|
+
aria-invalid=${createError ? "true" : "false"} aria-describedby=${createError ? "projectCreateError" : nothing}
|
|
55
|
+
@input=${onPathInput}>
|
|
56
|
+
${createError ? html`<div class="project-note project-error" id="projectCreateError" role="alert">${createError}</div>` : nothing}
|
|
57
|
+
<div class="project-create-actions"><button type="submit">${creating ? "Creating…" : "Create"}</button>
|
|
58
|
+
<button type="button" class="ghost" data-act="cancel-new">Cancel</button></div>
|
|
59
|
+
</form>` : html`<button type="button" class="project-row project-new" data-act="new-project">New project…</button>`}
|
|
43
60
|
</nav>`;
|
|
44
61
|
}
|
|
45
62
|
|
|
@@ -51,6 +68,12 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
|
|
|
51
68
|
let error = "";
|
|
52
69
|
// Only the newest read is drawn; a close drops the one out (latest.js).
|
|
53
70
|
const latest = createLatest();
|
|
71
|
+
// Creating a project must not invalidate an in-flight list read.
|
|
72
|
+
const createLatestAnswer = createLatest();
|
|
73
|
+
let createOpen = false;
|
|
74
|
+
let createPath = "~/";
|
|
75
|
+
let creating = false;
|
|
76
|
+
let createError = "";
|
|
54
77
|
|
|
55
78
|
const currentDir = () => scopeError() ? null : scope.dir || null;
|
|
56
79
|
const paint = (focus = false) => {
|
|
@@ -58,6 +81,8 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
|
|
|
58
81
|
render(pickerTemplate({
|
|
59
82
|
rows, loading, error, currentDir: currentDir(),
|
|
60
83
|
allCurrent: !scope.dir && !failedScope,
|
|
84
|
+
createOpen, createPath, creating, createError,
|
|
85
|
+
onPathInput: (event) => { createPath = event.currentTarget.value; },
|
|
61
86
|
}), shell.pop);
|
|
62
87
|
if (focus) {
|
|
63
88
|
const target = shell.pop.querySelector('.project-row[aria-current="page"]') ||
|
|
@@ -89,11 +114,53 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
|
|
|
89
114
|
if (!fresh()) return;
|
|
90
115
|
loading = false;
|
|
91
116
|
if (shell.isOpen()) {
|
|
92
|
-
|
|
117
|
+
// An open form keeps the caret: a late list must not pull focus to a row.
|
|
118
|
+
paint(focus && !createOpen);
|
|
93
119
|
shell.replace();
|
|
94
120
|
}
|
|
95
121
|
}
|
|
96
122
|
|
|
123
|
+
async function submitProject() {
|
|
124
|
+
if (creating) return;
|
|
125
|
+
const fresh = createLatestAnswer.begin();
|
|
126
|
+
creating = true;
|
|
127
|
+
createError = "";
|
|
128
|
+
paint();
|
|
129
|
+
shell.replace();
|
|
130
|
+
try {
|
|
131
|
+
const answer = await api("/api/projects", { method: "POST", body: JSON.stringify({ dir: createPath }) });
|
|
132
|
+
if (!fresh()) return;
|
|
133
|
+
win.location.assign(projectScopeHref(answer.dir, ""));
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (!fresh()) return;
|
|
136
|
+
createError = e?.body?.detail || e?.message || String(e);
|
|
137
|
+
creating = false;
|
|
138
|
+
paint();
|
|
139
|
+
shell.replace();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function openCreateForm() {
|
|
144
|
+
createOpen = true;
|
|
145
|
+
createPath = projectParent(rows.find((row) => row.default)?.root_label);
|
|
146
|
+
createError = "";
|
|
147
|
+
creating = false;
|
|
148
|
+
paint();
|
|
149
|
+
shell.replace();
|
|
150
|
+
const input = shell.pop.querySelector("#projectCreatePath");
|
|
151
|
+
input?.focus();
|
|
152
|
+
input?.setSelectionRange(input.value.length, input.value.length);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function closeCreateForm() {
|
|
156
|
+
createLatestAnswer.drop();
|
|
157
|
+
createOpen = false;
|
|
158
|
+
creating = false;
|
|
159
|
+
createError = "";
|
|
160
|
+
paint();
|
|
161
|
+
shell.replace();
|
|
162
|
+
}
|
|
163
|
+
|
|
97
164
|
const shell = createChipPopover({
|
|
98
165
|
id: "projectPicker", className: "ctx-popover project-picker", width: 380, el, anchor: "#scopePick",
|
|
99
166
|
summary: () => ({}), sheet: true, label: "Projects", doc, win,
|
|
@@ -101,10 +168,25 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
|
|
|
101
168
|
rows = [];
|
|
102
169
|
loading = true;
|
|
103
170
|
error = "";
|
|
171
|
+
createOpen = false;
|
|
172
|
+
createPath = "~/";
|
|
173
|
+
creating = false;
|
|
174
|
+
createError = "";
|
|
104
175
|
paint();
|
|
105
176
|
void load(true);
|
|
106
177
|
},
|
|
107
|
-
|
|
178
|
+
onSubmit: () => { void submitProject(); },
|
|
179
|
+
onAct: (name) => {
|
|
180
|
+
if (name === "new-project") openCreateForm();
|
|
181
|
+
else if (name === "cancel-new") closeCreateForm();
|
|
182
|
+
},
|
|
183
|
+
onClose: () => {
|
|
184
|
+
latest.drop();
|
|
185
|
+
createLatestAnswer.drop();
|
|
186
|
+
createOpen = false;
|
|
187
|
+
creating = false;
|
|
188
|
+
createError = "";
|
|
189
|
+
},
|
|
108
190
|
});
|
|
109
191
|
|
|
110
192
|
return { pop: shell.pop, isOpen: shell.isOpen, load };
|
|
@@ -133,6 +133,9 @@ const CONTEXT_CLIENT = "context:";
|
|
|
133
133
|
// A turn chi ran because a background task the session started finished
|
|
134
134
|
// unseen: task:<task id> (the Worker's finish wake).
|
|
135
135
|
const TASK_CLIENT = "task:";
|
|
136
|
+
// A durable schedule's firing (chi schedule, the schedule tools):
|
|
137
|
+
// system:schedule:<schedule id>.
|
|
138
|
+
const SCHEDULE_CLIENT = "system:schedule:";
|
|
136
139
|
|
|
137
140
|
// A task id as people read it: the random part a task_create id ends with
|
|
138
141
|
// (it starts with the time), else its first 8 chars (TaskRecord.short_id).
|
|
@@ -149,6 +152,7 @@ export function clientLabel(clientId) {
|
|
|
149
152
|
const id = String(clientId);
|
|
150
153
|
if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
|
|
151
154
|
if (id.startsWith(TASK_CLIENT) && id.length > TASK_CLIENT.length) return `task ${shortTaskId(id.slice(TASK_CLIENT.length))} finished`;
|
|
155
|
+
if (id.startsWith(SCHEDULE_CLIENT)) return "schedule";
|
|
152
156
|
const colon = id.indexOf(":");
|
|
153
157
|
return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
|
|
154
158
|
}
|