pocketrb 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- metadata +327 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Pocketrb
|
|
6
|
+
module Skills
|
|
7
|
+
# Loads and manages skills from SKILL.md files
|
|
8
|
+
class Loader
|
|
9
|
+
SKILL_FILE = "SKILL.md"
|
|
10
|
+
FRONTMATTER_REGEX = /\A---\s*\n(.+?)\n---\s*\n/m
|
|
11
|
+
|
|
12
|
+
attr_reader :workspace, :builtin_dir
|
|
13
|
+
|
|
14
|
+
def initialize(workspace:, builtin_dir: nil)
|
|
15
|
+
@workspace = Pathname.new(workspace)
|
|
16
|
+
@builtin_dir = if builtin_dir
|
|
17
|
+
Pathname.new(builtin_dir)
|
|
18
|
+
else
|
|
19
|
+
# Default to gem's builtin skills directory
|
|
20
|
+
Pocketrb.root.join("lib", "pocketrb", "skills", "builtin")
|
|
21
|
+
end
|
|
22
|
+
@skills_cache = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# List all available skills
|
|
26
|
+
# @param filter_unavailable [Boolean] Exclude unavailable skills
|
|
27
|
+
# @return [Array<Skill>]
|
|
28
|
+
def list_skills(filter_unavailable: true)
|
|
29
|
+
skills = []
|
|
30
|
+
|
|
31
|
+
# Load builtin skills
|
|
32
|
+
skills.concat(load_from_directory(@builtin_dir)) if @builtin_dir&.exist?
|
|
33
|
+
|
|
34
|
+
# Load workspace skills
|
|
35
|
+
skills_dir = @workspace.join("skills")
|
|
36
|
+
skills.concat(load_from_directory(skills_dir)) if skills_dir.exist?
|
|
37
|
+
|
|
38
|
+
# Filter unavailable if requested
|
|
39
|
+
skills = skills.select(&:available?) if filter_unavailable
|
|
40
|
+
|
|
41
|
+
skills
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Load a specific skill by name
|
|
45
|
+
# @param name [String] Skill name
|
|
46
|
+
# @return [Skill|nil]
|
|
47
|
+
def load_skill(name)
|
|
48
|
+
@skills_cache[name] ||= find_and_load_skill(name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get skills that should always be included
|
|
52
|
+
# @return [Array<Skill>]
|
|
53
|
+
def get_always_skills
|
|
54
|
+
list_skills.select(&:always?)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get skills triggered by a message
|
|
58
|
+
# @param text [String] Message text
|
|
59
|
+
# @return [Array<Skill>]
|
|
60
|
+
def get_triggered_skills(text)
|
|
61
|
+
list_skills.select { |s| s.matches?(text) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Build XML summary of all skills for context
|
|
65
|
+
# @return [String]
|
|
66
|
+
def build_skills_summary
|
|
67
|
+
skills = list_skills
|
|
68
|
+
|
|
69
|
+
return "" if skills.empty?
|
|
70
|
+
|
|
71
|
+
<<~XML
|
|
72
|
+
<available-skills>
|
|
73
|
+
#{skills.map(&:to_summary).join("\n")}
|
|
74
|
+
</available-skills>
|
|
75
|
+
XML
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Build full prompt content for specific skills
|
|
79
|
+
# @param skill_names [Array<String>] Skills to include
|
|
80
|
+
# @return [String]
|
|
81
|
+
def build_skills_prompt(skill_names)
|
|
82
|
+
skills = skill_names.filter_map { |name| load_skill(name) }
|
|
83
|
+
skills.map(&:to_prompt).join("\n\n")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Clear the skills cache
|
|
87
|
+
def clear_cache!
|
|
88
|
+
@skills_cache.clear
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def load_from_directory(dir)
|
|
94
|
+
return [] unless dir.exist?
|
|
95
|
+
|
|
96
|
+
skills = []
|
|
97
|
+
|
|
98
|
+
# Direct SKILL.md in skills directory
|
|
99
|
+
skill_file = dir.join(SKILL_FILE)
|
|
100
|
+
skills << parse_skill_file(skill_file) if skill_file.exist?
|
|
101
|
+
|
|
102
|
+
# Subdirectories with SKILL.md
|
|
103
|
+
Dir.glob(dir.join("*", SKILL_FILE)).each do |path|
|
|
104
|
+
skill = parse_skill_file(Pathname.new(path))
|
|
105
|
+
skills << skill if skill
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
skills.compact
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def find_and_load_skill(name)
|
|
112
|
+
# Check workspace skills
|
|
113
|
+
skill_path = @workspace.join("skills", name, SKILL_FILE)
|
|
114
|
+
return parse_skill_file(skill_path) if skill_path.exist?
|
|
115
|
+
|
|
116
|
+
# Check builtin skills
|
|
117
|
+
if @builtin_dir
|
|
118
|
+
builtin_path = @builtin_dir.join(name, SKILL_FILE)
|
|
119
|
+
return parse_skill_file(builtin_path) if builtin_path.exist?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_skill_file(path)
|
|
126
|
+
return nil unless path.exist?
|
|
127
|
+
|
|
128
|
+
content = File.read(path)
|
|
129
|
+
metadata, body = extract_frontmatter(content)
|
|
130
|
+
|
|
131
|
+
name = metadata["name"] || path.dirname.basename.to_s
|
|
132
|
+
description = metadata["description"] || extract_description(body)
|
|
133
|
+
|
|
134
|
+
Skill.new(
|
|
135
|
+
name: name,
|
|
136
|
+
description: description,
|
|
137
|
+
content: body,
|
|
138
|
+
path: path,
|
|
139
|
+
metadata: metadata
|
|
140
|
+
)
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
Pocketrb.logger.error("Failed to parse skill at #{path}: #{e.message}")
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def extract_frontmatter(content)
|
|
147
|
+
if content.match?(FRONTMATTER_REGEX)
|
|
148
|
+
match = content.match(FRONTMATTER_REGEX)
|
|
149
|
+
metadata = YAML.safe_load(match[1]) || {}
|
|
150
|
+
body = content.sub(FRONTMATTER_REGEX, "").strip
|
|
151
|
+
[metadata, body]
|
|
152
|
+
else
|
|
153
|
+
[{}, content.strip]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def extract_description(body)
|
|
158
|
+
# Extract first line or paragraph as description
|
|
159
|
+
first_line = body.lines.first&.strip
|
|
160
|
+
first_line&.gsub(/^#+\s*/, "") || "No description"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
module Skills
|
|
5
|
+
# Tool for modifying existing skills
|
|
6
|
+
class ModifyTool < Tools::Base
|
|
7
|
+
def name
|
|
8
|
+
"skill_modify"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"Modify an existing skill. Can update the content, description, triggers, or other properties."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parameters
|
|
16
|
+
{
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
skill_name: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Name of the skill to modify"
|
|
22
|
+
},
|
|
23
|
+
new_content: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "New content for the skill (replaces existing)"
|
|
26
|
+
},
|
|
27
|
+
append_content: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "Content to append to the skill"
|
|
30
|
+
},
|
|
31
|
+
new_description: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "New description for the skill"
|
|
34
|
+
},
|
|
35
|
+
add_triggers: {
|
|
36
|
+
type: "array",
|
|
37
|
+
items: { type: "string" },
|
|
38
|
+
description: "Triggers to add"
|
|
39
|
+
},
|
|
40
|
+
remove_triggers: {
|
|
41
|
+
type: "array",
|
|
42
|
+
items: { type: "string" },
|
|
43
|
+
description: "Triggers to remove"
|
|
44
|
+
},
|
|
45
|
+
set_always: {
|
|
46
|
+
type: "boolean",
|
|
47
|
+
description: "Set whether skill is always loaded"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
required: ["skill_name"]
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def execute(
|
|
55
|
+
skill_name:,
|
|
56
|
+
new_content: nil,
|
|
57
|
+
append_content: nil,
|
|
58
|
+
new_description: nil,
|
|
59
|
+
add_triggers: nil,
|
|
60
|
+
remove_triggers: nil,
|
|
61
|
+
set_always: nil
|
|
62
|
+
)
|
|
63
|
+
skill_file = workspace.join("skills", skill_name, "SKILL.md")
|
|
64
|
+
|
|
65
|
+
return error("Skill '#{skill_name}' not found") unless skill_file.exist?
|
|
66
|
+
|
|
67
|
+
# Parse existing skill
|
|
68
|
+
content = File.read(skill_file)
|
|
69
|
+
metadata, body = parse_frontmatter(content)
|
|
70
|
+
|
|
71
|
+
# Apply modifications
|
|
72
|
+
metadata["description"] = new_description if new_description
|
|
73
|
+
|
|
74
|
+
if add_triggers
|
|
75
|
+
metadata["triggers"] ||= []
|
|
76
|
+
metadata["triggers"] = (metadata["triggers"] + add_triggers).uniq
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if remove_triggers
|
|
80
|
+
metadata["triggers"] ||= []
|
|
81
|
+
metadata["triggers"] -= remove_triggers
|
|
82
|
+
metadata.delete("triggers") if metadata["triggers"].empty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
metadata["always"] = set_always unless set_always.nil?
|
|
86
|
+
|
|
87
|
+
if new_content
|
|
88
|
+
body = new_content
|
|
89
|
+
elsif append_content
|
|
90
|
+
body = "#{body}\n\n#{append_content}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Write updated skill
|
|
94
|
+
updated_content = build_skill_file(metadata, body)
|
|
95
|
+
File.write(skill_file, updated_content)
|
|
96
|
+
|
|
97
|
+
success("Modified skill '#{skill_name}'")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def parse_frontmatter(content)
|
|
103
|
+
if content.match?(/\A---\s*\n(.+?)\n---\s*\n/m)
|
|
104
|
+
match = content.match(/\A---\s*\n(.+?)\n---\s*\n/m)
|
|
105
|
+
metadata = YAML.safe_load(match[1]) || {}
|
|
106
|
+
body = content.sub(/\A---\s*\n.+?\n---\s*\n/m, "").strip
|
|
107
|
+
[metadata, body]
|
|
108
|
+
else
|
|
109
|
+
[{}, content.strip]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_skill_file(metadata, body)
|
|
114
|
+
<<~MD
|
|
115
|
+
---
|
|
116
|
+
#{metadata.to_yaml.lines[1..].join}---
|
|
117
|
+
|
|
118
|
+
#{body}
|
|
119
|
+
MD
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
module Skills
|
|
5
|
+
# Represents a loaded skill from SKILL.md
|
|
6
|
+
class Skill
|
|
7
|
+
attr_reader :name, :description, :content, :path, :metadata
|
|
8
|
+
|
|
9
|
+
def initialize(name:, description:, content:, path:, metadata: {})
|
|
10
|
+
@name = name
|
|
11
|
+
@description = description
|
|
12
|
+
@content = content
|
|
13
|
+
@path = path
|
|
14
|
+
@metadata = metadata
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Check if skill should always be included in context
|
|
18
|
+
def always?
|
|
19
|
+
metadata[:always] == true || metadata["always"] == true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Check if skill is available (meets requirements)
|
|
23
|
+
def available?
|
|
24
|
+
requires = metadata[:requires] || metadata["requires"]
|
|
25
|
+
return true unless requires
|
|
26
|
+
|
|
27
|
+
Array(requires).all? { |req| check_requirement(req) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get skill triggers/keywords
|
|
31
|
+
def triggers
|
|
32
|
+
triggers = metadata[:triggers] || metadata["triggers"] || []
|
|
33
|
+
Array(triggers)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check if a message should trigger this skill
|
|
37
|
+
def matches?(text)
|
|
38
|
+
return false if triggers.empty?
|
|
39
|
+
|
|
40
|
+
text_lower = text.downcase
|
|
41
|
+
triggers.any? { |t| text_lower.include?(t.downcase) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get full prompt content for this skill
|
|
45
|
+
def to_prompt
|
|
46
|
+
<<~PROMPT
|
|
47
|
+
<skill name="#{name}">
|
|
48
|
+
#{content}
|
|
49
|
+
</skill>
|
|
50
|
+
PROMPT
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Summary for skills list
|
|
54
|
+
def to_summary
|
|
55
|
+
"- #{name}: #{description}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def check_requirement(req)
|
|
61
|
+
case req
|
|
62
|
+
when /^env:(.+)/
|
|
63
|
+
!ENV[::Regexp.last_match(1)].nil?
|
|
64
|
+
when /^file:(.+)/
|
|
65
|
+
File.exist?(::Regexp.last_match(1))
|
|
66
|
+
when /^tool:(.+)/
|
|
67
|
+
# Would need tool registry context
|
|
68
|
+
true
|
|
69
|
+
else
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Pocketrb
|
|
6
|
+
module Tools
|
|
7
|
+
# Manages background jobs for long-running commands
|
|
8
|
+
class BackgroundJobManager
|
|
9
|
+
MAX_JOBS = 50
|
|
10
|
+
MAX_COMPLETED_JOBS = 20
|
|
11
|
+
MAX_JOB_AGE = 24 * 60 * 60 # 24 hours
|
|
12
|
+
|
|
13
|
+
# Commands that should auto-run in background
|
|
14
|
+
LONG_RUNNING_PATTERNS = [
|
|
15
|
+
/^apt(-get)?\s+(install|update|upgrade|dist-upgrade|autoremove)/i,
|
|
16
|
+
/^(npm|yarn|pnpm)\s+(install|ci|update|build|run\s+build)/i,
|
|
17
|
+
/^pip3?\s+install/i,
|
|
18
|
+
/^gem\s+install/i,
|
|
19
|
+
/^bundle\s+(install|update)/i,
|
|
20
|
+
/^cargo\s+(build|install|run)/i,
|
|
21
|
+
/^make\b/i,
|
|
22
|
+
/^docker\s+(build|pull|push|run)/i,
|
|
23
|
+
/^wget\s/i,
|
|
24
|
+
/^git\s+(clone|pull|fetch)/i,
|
|
25
|
+
/sleep\s+\d{2,}/, # sleep 10+ seconds
|
|
26
|
+
/^python3?\s+.*\.py/i,
|
|
27
|
+
/^ruby\s+.*\.rb/i,
|
|
28
|
+
/^node\s+.*\.js/i,
|
|
29
|
+
/nohup\s/i,
|
|
30
|
+
/&\s*$/ # ends with &
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
attr_reader :jobs_dir
|
|
34
|
+
|
|
35
|
+
def initialize(workspace:)
|
|
36
|
+
@workspace = Pathname.new(workspace)
|
|
37
|
+
@jobs_dir = resolve_jobs_dir
|
|
38
|
+
@available = setup_jobs_dir!
|
|
39
|
+
cleanup_stale_jobs if @available
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Check if job manager is available (directory accessible)
|
|
43
|
+
def available?
|
|
44
|
+
@available
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def resolve_jobs_dir
|
|
50
|
+
# Don't create .pocketrb in root filesystem
|
|
51
|
+
if @workspace.to_s == "/" || !@workspace.writable?
|
|
52
|
+
Pathname.new(Dir.home).join(".pocketrb", "jobs")
|
|
53
|
+
else
|
|
54
|
+
@workspace.join(".pocketrb", "jobs")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def setup_jobs_dir!
|
|
59
|
+
FileUtils.mkdir_p(@jobs_dir)
|
|
60
|
+
true
|
|
61
|
+
rescue Errno::EACCES, Errno::EROFS => e
|
|
62
|
+
Pocketrb.logger.warn("Cannot create jobs directory #{@jobs_dir}: #{e.message}")
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
public
|
|
67
|
+
|
|
68
|
+
# Check if command should auto-run in background
|
|
69
|
+
def long_running?(command)
|
|
70
|
+
return false if command.nil? || command.empty?
|
|
71
|
+
|
|
72
|
+
LONG_RUNNING_PATTERNS.any? { |pattern| command.match?(pattern) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Start a background job
|
|
76
|
+
def start(command:, working_dir: nil, name: nil)
|
|
77
|
+
cleanup_stale_jobs
|
|
78
|
+
|
|
79
|
+
job_id = "job_#{Time.now.to_i}_#{rand(10_000)}"
|
|
80
|
+
job_dir = @jobs_dir.join(job_id)
|
|
81
|
+
FileUtils.mkdir_p(job_dir)
|
|
82
|
+
|
|
83
|
+
log_file = job_dir.join("output.log")
|
|
84
|
+
pid_file = job_dir.join("pid")
|
|
85
|
+
cmd_file = job_dir.join("command")
|
|
86
|
+
name_file = job_dir.join("name")
|
|
87
|
+
status_file = job_dir.join("status")
|
|
88
|
+
|
|
89
|
+
File.write(cmd_file, command)
|
|
90
|
+
File.write(name_file, name || command[0..50])
|
|
91
|
+
File.write(status_file, "running")
|
|
92
|
+
|
|
93
|
+
work_dir = working_dir || @workspace.to_s
|
|
94
|
+
|
|
95
|
+
pid = Process.spawn(
|
|
96
|
+
"bash", "-lc", command,
|
|
97
|
+
chdir: work_dir,
|
|
98
|
+
out: [log_file.to_s, "a"],
|
|
99
|
+
err: [log_file.to_s, "a"],
|
|
100
|
+
pgroup: true
|
|
101
|
+
)
|
|
102
|
+
Process.detach(pid)
|
|
103
|
+
|
|
104
|
+
File.write(pid_file, pid.to_s)
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
job_id: job_id,
|
|
108
|
+
pid: pid,
|
|
109
|
+
log_file: log_file.to_s
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# List all jobs
|
|
114
|
+
def list
|
|
115
|
+
return [] unless @jobs_dir.exist?
|
|
116
|
+
|
|
117
|
+
Dir.glob(@jobs_dir.join("job_*")).filter_map do |job_dir|
|
|
118
|
+
job_id = File.basename(job_dir)
|
|
119
|
+
job_dir_path = Pathname.new(job_dir)
|
|
120
|
+
|
|
121
|
+
pid_file = job_dir_path.join("pid")
|
|
122
|
+
name_file = job_dir_path.join("name")
|
|
123
|
+
|
|
124
|
+
next unless pid_file.exist?
|
|
125
|
+
|
|
126
|
+
pid = File.read(pid_file).strip.to_i
|
|
127
|
+
name = name_file.exist? ? File.read(name_file).strip : "unknown"
|
|
128
|
+
running = process_running?(pid)
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
job_id: job_id,
|
|
132
|
+
pid: pid,
|
|
133
|
+
name: name,
|
|
134
|
+
running: running,
|
|
135
|
+
created_at: File.mtime(job_dir)
|
|
136
|
+
}
|
|
137
|
+
end.sort_by { |j| j[:created_at] }.reverse
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Get job status and output
|
|
141
|
+
def status(job_id)
|
|
142
|
+
job_dir = @jobs_dir.join(job_id)
|
|
143
|
+
return nil unless job_dir.exist?
|
|
144
|
+
|
|
145
|
+
pid_file = job_dir.join("pid")
|
|
146
|
+
log_file = job_dir.join("output.log")
|
|
147
|
+
cmd_file = job_dir.join("command")
|
|
148
|
+
name_file = job_dir.join("name")
|
|
149
|
+
|
|
150
|
+
pid = pid_file.exist? ? File.read(pid_file).strip.to_i : nil
|
|
151
|
+
running = pid ? process_running?(pid) : false
|
|
152
|
+
output = log_file.exist? ? truncate_output(File.read(log_file)) : ""
|
|
153
|
+
command = cmd_file.exist? ? File.read(cmd_file) : "unknown"
|
|
154
|
+
name = name_file.exist? ? File.read(name_file).strip : "unknown"
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
job_id: job_id,
|
|
158
|
+
pid: pid,
|
|
159
|
+
name: name,
|
|
160
|
+
running: running,
|
|
161
|
+
output: output,
|
|
162
|
+
command: command
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Get job output (tail)
|
|
167
|
+
def output(job_id, lines: 50)
|
|
168
|
+
log_file = @jobs_dir.join(job_id, "output.log")
|
|
169
|
+
return nil unless log_file.exist?
|
|
170
|
+
|
|
171
|
+
`tail -n #{lines} #{log_file}`
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Kill a job
|
|
175
|
+
def kill(job_id)
|
|
176
|
+
pid_file = @jobs_dir.join(job_id, "pid")
|
|
177
|
+
return false unless pid_file.exist?
|
|
178
|
+
|
|
179
|
+
pid = File.read(pid_file).strip.to_i
|
|
180
|
+
return false unless process_running?(pid)
|
|
181
|
+
|
|
182
|
+
begin
|
|
183
|
+
Process.kill("-TERM", pid)
|
|
184
|
+
sleep 0.5
|
|
185
|
+
Process.kill("-KILL", pid) if process_running?(pid)
|
|
186
|
+
|
|
187
|
+
status_file = @jobs_dir.join(job_id, "status")
|
|
188
|
+
File.write(status_file, "killed")
|
|
189
|
+
true
|
|
190
|
+
rescue Errno::ESRCH
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Clean up old jobs
|
|
196
|
+
def cleanup_stale_jobs
|
|
197
|
+
return 0 unless @jobs_dir.exist?
|
|
198
|
+
|
|
199
|
+
jobs = Dir.glob(@jobs_dir.join("job_*")).filter_map do |job_dir|
|
|
200
|
+
pid_file = File.join(job_dir, "pid")
|
|
201
|
+
next nil unless File.exist?(pid_file)
|
|
202
|
+
|
|
203
|
+
pid = begin
|
|
204
|
+
File.read(pid_file).strip.to_i
|
|
205
|
+
rescue StandardError
|
|
206
|
+
0
|
|
207
|
+
end
|
|
208
|
+
running = pid.positive? && process_running?(pid)
|
|
209
|
+
created = begin
|
|
210
|
+
File.mtime(job_dir)
|
|
211
|
+
rescue StandardError
|
|
212
|
+
Time.now
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
{ dir: job_dir, running: running, created: created }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
completed_jobs = jobs.reject { |j| j[:running] }.sort_by { |j| j[:created] }
|
|
219
|
+
removed = 0
|
|
220
|
+
|
|
221
|
+
# Remove jobs older than MAX_JOB_AGE
|
|
222
|
+
cutoff = Time.now - MAX_JOB_AGE
|
|
223
|
+
completed_jobs.each do |job|
|
|
224
|
+
if job[:created] < cutoff
|
|
225
|
+
FileUtils.rm_rf(job[:dir])
|
|
226
|
+
removed += 1
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
completed_jobs.reject! { |j| j[:created] < cutoff }
|
|
231
|
+
|
|
232
|
+
# Keep only MAX_COMPLETED_JOBS
|
|
233
|
+
while completed_jobs.length > MAX_COMPLETED_JOBS
|
|
234
|
+
oldest = completed_jobs.shift
|
|
235
|
+
FileUtils.rm_rf(oldest[:dir])
|
|
236
|
+
removed += 1
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
removed
|
|
240
|
+
rescue StandardError => e
|
|
241
|
+
Pocketrb.logger.warn("Job cleanup error: #{e.message}")
|
|
242
|
+
0
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
def process_running?(pid)
|
|
248
|
+
Process.kill(0, pid)
|
|
249
|
+
true
|
|
250
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
251
|
+
false
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def truncate_output(output, max_size: 100_000)
|
|
255
|
+
return output if output.length <= max_size
|
|
256
|
+
|
|
257
|
+
"#{output[0...max_size]}\n... (truncated, #{output.length - max_size} more characters)"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|